ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! SpecRelationCycle - Detects circular dependencies in SpecRelations
//!
//! This rule checks for cyclic dependencies between Spec types.

use std::collections::{HashMap, HashSet};

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

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

/// SpecRelationCycle rule
///
/// Detects circular dependencies in SpecRelation definitions.
///
/// # Rule Code
/// RS005 (Ryo Spec)
///
/// # Detection
/// 1. Build a dependency graph from Spec TypeAliases
/// 2. Detect cycles using DFS
/// 3. Report cycles found
///
/// # Example Violation
/// ```ignore
/// // Cycle: A -> B -> C -> A
/// type ASpec = Spec<Group, A, Relations![DependsOn(B)]>;
/// type BSpec = Spec<Group, B, Relations![DependsOn(C)]>;
/// type CSpec = Spec<Group, C, Relations![DependsOn(A)]>;  // Creates cycle!
/// ```
///
/// # Fix
/// This is a report-only rule. Manual review is required to break the cycle.
pub struct SpecRelationCycle {
    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
    spec_suffix: String,
}

impl SpecRelationCycle {
    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(),
        }
    }

    /// Build dependency graph from Spec TypeAliases
    ///
    /// Returns: Map from base type name to (symbol_id, dependencies)
    fn build_dependency_graph(
        &self,
        ctx: &AnalysisContext,
        symbols: &[SymbolId],
    ) -> HashMap<String, (SymbolId, Vec<String>)> {
        let mut graph: HashMap<String, (SymbolId, Vec<String>)> = HashMap::new();

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

            let alias_name = path.name();

            if !self.is_spec_alias(alias_name) {
                continue;
            }

            let base_type = match self.extract_base_type(alias_name) {
                Some(bt) => bt.to_string(),
                None => continue,
            };

            // Get dependencies (types used by this Spec)
            let typeflow = ctx.typeflow_graph();
            let mut deps = Vec::new();

            for used_id in typeflow.types_used_by(symbol_id) {
                if let Some(used_path) = ctx.registry.path(used_id) {
                    let kind = ctx.registry.kind(used_id);
                    if matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
                        let dep_name = used_path.name().to_string();
                        // Skip self-reference
                        if dep_name != base_type {
                            deps.push(dep_name);
                        }
                    }
                }
            }

            graph.insert(base_type, (symbol_id, deps));
        }

        graph
    }

    /// Detect cycles in the dependency graph using DFS
    ///
    /// Returns: List of cycles, each cycle is a list of type names
    fn detect_cycles(&self, graph: &HashMap<String, (SymbolId, Vec<String>)>) -> Vec<Vec<String>> {
        let mut cycles = Vec::new();
        let mut visited = HashSet::new();
        let mut rec_stack = HashSet::new();
        let mut path = Vec::new();

        for start_node in graph.keys() {
            if !visited.contains(start_node) {
                self.dfs_find_cycles(
                    graph,
                    start_node,
                    &mut visited,
                    &mut rec_stack,
                    &mut path,
                    &mut cycles,
                );
            }
        }

        cycles
    }

    fn dfs_find_cycles(
        &self,
        graph: &HashMap<String, (SymbolId, Vec<String>)>,
        node: &str,
        visited: &mut HashSet<String>,
        rec_stack: &mut HashSet<String>,
        path: &mut Vec<String>,
        cycles: &mut Vec<Vec<String>>,
    ) {
        visited.insert(node.to_string());
        rec_stack.insert(node.to_string());
        path.push(node.to_string());

        if let Some((_, deps)) = graph.get(node) {
            for dep in deps {
                // Only consider dependencies that are also Spec types
                if !graph.contains_key(dep) {
                    continue;
                }

                if !visited.contains(dep) {
                    self.dfs_find_cycles(graph, dep, visited, rec_stack, path, cycles);
                } else if rec_stack.contains(dep) {
                    // Found a cycle - extract it from path
                    if let Some(cycle_start) = path.iter().position(|n| n == dep) {
                        let cycle: Vec<String> = path[cycle_start..].to_vec();
                        // Only add if we haven't seen this cycle before (normalized)
                        let normalized = self.normalize_cycle(&cycle);
                        if !cycles.iter().any(|c| self.normalize_cycle(c) == normalized) {
                            cycles.push(cycle);
                        }
                    }
                }
            }
        }

        path.pop();
        rec_stack.remove(node);
    }

    /// Normalize a cycle for comparison (rotate to start with smallest element)
    fn normalize_cycle(&self, cycle: &[String]) -> Vec<String> {
        if cycle.is_empty() {
            return Vec::new();
        }

        let min_idx = cycle
            .iter()
            .enumerate()
            .min_by_key(|(_, s)| s.as_str())
            .map(|(i, _)| i)
            .unwrap_or(0);

        let mut normalized = Vec::with_capacity(cycle.len());
        for i in 0..cycle.len() {
            normalized.push(cycle[(min_idx + i) % cycle.len()].clone());
        }
        normalized
    }
}

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

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

impl Suggest for SpecRelationCycle {
    fn name(&self) -> &'static str {
        "spec-relation-cycle"
    }

    fn description(&self) -> &str {
        "Detects circular dependencies in SpecRelation definitions"
    }

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

    fn safety_level(&self) -> SafetyLevel {
        SafetyLevel::Manual // Requires manual decision to break cycle
    }

    fn priority_weight(&self) -> f32 {
        2.5 // High priority - cycles are architectural issues
    }

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

        // Get all TypeAliases to check
        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
            ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
        } else {
            symbols.to_vec()
        };

        // Build dependency graph
        let graph = self.build_dependency_graph(ctx, &symbols_to_check);

        // Detect cycles
        let cycles = self.detect_cycles(&graph);

        // Create opportunities for each cycle
        for cycle in cycles {
            if cycle.is_empty() {
                continue;
            }

            // Use the first node in the cycle for the location
            let first_type = &cycle[0];
            let (symbol_id, _) = match graph.get(first_type) {
                Some(data) => data,
                None => continue,
            };

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

            // Collect all symbol IDs in the cycle
            let cycle_symbols: Vec<SymbolId> = cycle
                .iter()
                .filter_map(|t| graph.get(t).map(|(id, _)| *id))
                .collect();

            let cycle_str = cycle.join(" -> ") + " -> " + &cycle[0];

            let opp = self.create_lint_opportunity(
                OpportunityId::new(next_id),
                cycle_symbols,
                location,
                format!("Circular dependency detected: {}", cycle_str),
                LintDetails {
                    suggestion: Some(format!(
                        "Break the cycle by removing one dependency in: {}",
                        cycle_str
                    )),
                    expected: Some("No cycles in Spec dependencies".to_string()),
                    actual: Some(format!("Cycle found: {}", cycle_str)),
                },
            );

            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 SpecRelationCycle {
    fn code(&self) -> &'static str {
        "RS005"
    }

    fn default_severity(&self) -> LintSeverity {
        LintSeverity::Error // Cycles are errors
    }
}

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

    #[test]
    fn test_is_spec_alias() {
        let rule = SpecRelationCycle::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_extract_base_type() {
        let rule = SpecRelationCycle::new();

        assert_eq!(rule.extract_base_type("TaskSpec"), Some("Task"));
        assert_eq!(rule.extract_base_type("UserSpec"), Some("User"));
        assert_eq!(rule.extract_base_type("Spec"), None);
    }

    #[test]
    fn test_normalize_cycle() {
        let rule = SpecRelationCycle::new();

        let cycle = vec!["C".to_string(), "A".to_string(), "B".to_string()];
        let normalized = rule.normalize_cycle(&cycle);
        assert_eq!(normalized, vec!["A", "B", "C"]);

        let cycle2 = vec!["B".to_string(), "C".to_string(), "A".to_string()];
        let normalized2 = rule.normalize_cycle(&cycle2);
        assert_eq!(normalized2, vec!["A", "B", "C"]);
    }
}