ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! MissingRelation - Suggests Relations based on struct field types
//!
//! This rule analyzes struct fields to suggest SpecRelations.

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

use super::{create_spec_opportunity, is_framework_type, SpecDetails, SpecSuggest};
use crate::{
    MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestError,
    SuggestLocation, SuggestOpportunity, SuggestResult,
};

/// MissingRelation rule
///
/// Analyzes struct fields to suggest SpecRelations that could be added.
///
/// # Rule Code
/// RS006 (Ryo Spec)
///
/// # Detection
/// 1. Find structs that have Spec TypeAliases
/// 2. Analyze field types to find references to other domain types
/// 3. Suggest Relations for those references
///
/// # Example
/// ```ignore
/// pub struct Order {
///     user_id: UserId,           // References User
///     items: Vec<OrderItem>,     // Contains OrderItem
/// }
///
/// type OrderSpec = Spec<DomainGroup, Order>;  // No relations defined
///
/// // Suggestion: Add Relations![RelatedTo(User), PartOf(OrderItem)]
/// ```
///
/// # Fix
/// Generates `AddSpec` MutationSpec with inferred relations.
pub struct MissingRelation {
    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
    suffix: String,
    /// Default group name for generated Specs
    default_group: String,
}

impl MissingRelation {
    pub fn new() -> Self {
        Self {
            suffix: "Spec".to_string(),
            default_group: "DomainGroup".to_string(),
        }
    }

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

    /// Set the default group name
    pub fn with_group(mut self, group: impl Into<String>) -> Self {
        self.default_group = group.into();
        self
    }

    /// Find the Spec TypeAlias for a given type name
    fn find_spec_for_type(
        &self,
        ctx: &AnalysisContext,
        type_name: &str,
    ) -> Option<(SymbolId, SymbolPath)> {
        let spec_name = format!("{}{}", type_name, self.suffix);

        for symbol_id in ctx.registry.iter_by_kind(SymbolKind::TypeAlias) {
            if let Some(path) = ctx.registry.path(symbol_id) {
                if path.name() == spec_name {
                    return Some((symbol_id, path.clone()));
                }
            }
        }
        None
    }

    /// Find the struct for a given type name
    fn find_struct_for_type(&self, ctx: &AnalysisContext, type_name: &str) -> Option<SymbolId> {
        for symbol_id in ctx.registry.iter_by_kind(SymbolKind::Struct) {
            if let Some(path) = ctx.registry.path(symbol_id) {
                if path.name() == type_name {
                    return Some(symbol_id);
                }
            }
        }
        None
    }

    /// Analyze a struct's dependencies to find potential relations
    fn analyze_struct_relations(
        &self,
        ctx: &AnalysisContext,
        struct_id: SymbolId,
        struct_name: &str,
    ) -> Vec<String> {
        let mut relations = Vec::new();
        let typeflow = ctx.typeflow_graph();

        // Get types used by this struct (field types, via TypeFlow)
        for used_id in typeflow.types_used_by(struct_id) {
            if let Some(path) = ctx.registry.path(used_id) {
                let kind = ctx.registry.kind(used_id);

                // Only consider structs and enums as potential relations
                if !matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
                    continue;
                }

                let used_name = path.name();

                // Skip common types and self-references
                if is_framework_type(used_name) || used_name == struct_name {
                    continue;
                }

                // Check if this type also has a Spec (indicates it's a domain type)
                if self.find_spec_for_type(ctx, used_name).is_some() {
                    relations.push(used_name.to_string());
                }
            }
        }

        relations
    }

    /// Check if a Spec already has relations defined
    fn spec_has_relations(&self, ctx: &AnalysisContext, spec_id: SymbolId) -> bool {
        let typeflow = ctx.typeflow_graph();

        // If the Spec uses many types, it likely already has relations
        let used_count = typeflow.types_used_by(spec_id).count();
        // Base type + Group = 2, so more than 2 indicates relations
        used_count > 2
    }
}

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

/// Rule code for MissingRelation
const RULE_CODE: &str = "RS006";

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

impl Suggest for MissingRelation {
    fn name(&self) -> &'static str {
        "missing-relation"
    }

    fn description(&self) -> &str {
        "Suggests Relations based on struct field types"
    }

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

    fn safety_level(&self) -> SafetyLevel {
        SafetyLevel::Confirm // Suggestions need user confirmation
    }

    fn priority_weight(&self) -> f32 {
        0.8 // Lower priority - these are suggestions, not 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()
        };

        for spec_id in symbols_to_check {
            let path = match ctx.registry.path(spec_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;
            }

            // Extract base type name
            let base_type = match self.extract_base_type(alias_name) {
                Some(bt) => bt,
                None => continue,
            };

            // Skip if Spec already has relations
            if self.spec_has_relations(ctx, spec_id) {
                continue;
            }

            // Find the corresponding struct
            let struct_id = match self.find_struct_for_type(ctx, base_type) {
                Some(id) => id,
                None => continue,
            };

            // Analyze struct for potential relations
            let relations = self.analyze_struct_relations(ctx, struct_id, base_type);

            // Skip if no relations found
            if relations.is_empty() {
                continue;
            }

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

            let relation_str = relations.join(", ");
            let suggested_relations = relations
                .iter()
                .map(|name| format!("RelatedTo({})", name))
                .collect::<Vec<_>>()
                .join(", ");

            let opp = create_spec_opportunity(
                RULE_CODE,
                OpportunityId::new(next_id),
                vec![spec_id, struct_id],
                location,
                format!(
                    "Spec `{}` could define relations to: {}",
                    alias_name, relation_str
                ),
                0.7, // Medium confidence - these are suggestions
                SpecDetails {
                    alias_name: Some(alias_name.to_string()),
                    base_type: Some(base_type.to_string()),
                    group: Some(self.default_group.clone()),
                    related_types: relations.clone(),
                    suggestion: Some(format!("Add Relations![{}]", suggested_relations)),
                },
            )
            .with_related_types(relations);

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

        opportunities
    }

    fn to_mutation_specs(
        &self,
        ctx: &AnalysisContext,
        opportunity: &SuggestOpportunity,
    ) -> SuggestResult<Vec<MutationSpec>> {
        // Get the Spec symbol
        let spec_id = match opportunity.targets.first() {
            Some(id) => *id,
            None => return Ok(Vec::new()),
        };

        let path = match ctx.registry.path(spec_id) {
            Some(p) => p,
            None => return Ok(Vec::new()),
        };

        let alias_name = path.name();
        let base_type = match self.extract_base_type(alias_name) {
            Some(bt) => bt.to_string(),
            None => return Ok(Vec::new()),
        };

        let module_path =
            self.get_module_path(path)
                .ok_or_else(|| SuggestError::ModulePathResolution {
                    path: path.to_string(),
                })?;

        // Resolve module_id from module_path
        let module_id = match ctx.registry.lookup(&module_path) {
            Some(id) => id,
            None => return Ok(Vec::new()),
        };

        // Find the struct and analyze relations
        let struct_id = match self.find_struct_for_type(ctx, &base_type) {
            Some(id) => id,
            None => return Ok(Vec::new()),
        };

        let relations = self.analyze_struct_relations(ctx, struct_id, &base_type);

        // Convert to SpecRelation format
        let spec_relations: Vec<SpecRelation> = relations
            .into_iter()
            .map(|target| SpecRelation::new(SpecRelationKind::RelatedTo, target))
            .collect();

        Ok(vec![MutationSpec::AddSpec {
            type_id: struct_id,
            module_id,
            group: self.default_group.clone(),
            alias_name: Some(alias_name.to_string()),
            relations: spec_relations,
        }])
    }
}

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

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

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

    #[test]
    fn test_is_framework_type() {
        assert!(is_framework_type("String"));
        assert!(is_framework_type("Vec"));
        assert!(is_framework_type("u64"));
        assert!(is_framework_type("Option"));

        assert!(!is_framework_type("Task"));
        assert!(!is_framework_type("User"));
        assert!(!is_framework_type("Order"));
    }

    #[test]
    fn test_with_group() {
        let rule = MissingRelation::new().with_group("CustomGroup");
        assert_eq!(rule.default_group, "CustomGroup");
    }
}