ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! SpecRelationToField - Suggests struct fields based on Spec relations
//!
//! This rule analyzes Spec relations to suggest missing struct fields.

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

use super::{is_framework_type, SpecSuggest};
use crate::{
    MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestLocation,
    SuggestOpportunity, SuggestResult,
};
use ryo_executor::Visibility;

/// SpecRelationToField rule
///
/// Analyzes Spec relations (DependsOn, RelatedTo, PartOf) to suggest
/// corresponding struct fields that should exist.
///
/// # Rule Code
/// RS007 (Ryo Spec)
///
/// # Detection
/// 1. Find Spec TypeAliases with relations (DependsOn, RelatedTo, PartOf)
/// 2. Get the wrapped struct type
/// 3. Check if struct has fields referencing the relation target
/// 4. Suggest AddField if missing
///
/// # Example
/// ```ignore
/// type UserSpec = Spec<DomainGroup, User, [DependsOn<Order>]>;
///
/// pub struct User {
///     id: UserId,
///     name: String,
///     // Missing: order field!
/// }
///
/// // Suggestion: Add `order_id: OrderId` or `orders: Vec<Order>` field
/// ```
///
/// # Fix
/// Generates `AddField` MutationSpec to add the missing field.
pub struct SpecRelationToField {
    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
    suffix: String,
}

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

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

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

    /// Get field names of a struct
    fn get_struct_fields(&self, ctx: &AnalysisContext, struct_id: SymbolId) -> Vec<String> {
        let graph = ctx.code_graph();
        let mut fields = Vec::new();

        for child_id in graph.children_of(struct_id) {
            if let Some(SymbolKind::Field) = ctx.registry.kind(child_id) {
                if let Some(path) = ctx.registry.path(child_id) {
                    fields.push(path.name().to_string());
                }
            }
        }

        fields
    }

    /// Get field types of a struct (types used by struct fields)
    fn get_struct_field_types(&self, ctx: &AnalysisContext, struct_id: SymbolId) -> Vec<String> {
        let typeflow = ctx.typeflow_graph();
        let mut types = Vec::new();

        // Types used by struct fields (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);
                if matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
                    types.push(path.name().to_string());
                }
            }
        }

        types
    }

    /// Extract types referenced by a Spec TypeAlias (relation targets)
    fn extract_relation_targets(
        &self,
        ctx: &AnalysisContext,
        spec_id: SymbolId,
        base_type: &str,
    ) -> Vec<String> {
        let typeflow = ctx.typeflow_graph();
        let mut targets = Vec::new();

        for used_id in typeflow.types_used_by(spec_id) {
            if let Some(path) = ctx.registry.path(used_id) {
                let kind = ctx.registry.kind(used_id);

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

                let used_name = path.name();

                // Skip framework types, self-references, and the base type
                if is_framework_type(used_name) || used_name == base_type {
                    continue;
                }

                targets.push(used_name.to_string());
            }
        }

        targets
    }

    /// Check if struct has a field referencing the target type
    fn has_field_for_relation(
        &self,
        ctx: &AnalysisContext,
        struct_id: SymbolId,
        target_type: &str,
    ) -> bool {
        let fields = self.get_struct_fields(ctx, struct_id);
        let field_types = self.get_struct_field_types(ctx, struct_id);

        // Check if any field name contains the target (e.g., "order_id", "orders")
        let target_lower = target_type.to_lowercase();
        let has_field_name = fields
            .iter()
            .any(|f| f.to_lowercase().contains(&target_lower));

        // Check if any field type references the target
        let has_field_type = field_types.iter().any(|t| {
            t == target_type || t == &format!("{}Id", target_type) || t.contains(target_type)
        });

        has_field_name || has_field_type
    }

    /// Generate field name suggestion for a relation target
    fn suggest_field_name(&self, target_type: &str) -> String {
        // Convert PascalCase to snake_case and add _id suffix
        let mut result = String::new();
        for (i, c) in target_type.chars().enumerate() {
            if c.is_uppercase() && i > 0 {
                result.push('_');
            }
            result.push(c.to_ascii_lowercase());
        }
        format!("{}_id", result)
    }

    /// Generate field type suggestion for a relation target
    fn suggest_field_type(&self, target_type: &str) -> String {
        format!("{}Id", target_type)
    }
}

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

/// Rule code for SpecRelationToField
const RULE_CODE: &str = "RS007";

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

impl Suggest for SpecRelationToField {
    fn name(&self) -> &'static str {
        "spec-relation-to-field"
    }

    fn description(&self) -> &str {
        "Suggests struct fields based on Spec relations"
    }

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

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

    fn priority_weight(&self) -> f32 {
        1.0 // Medium priority
    }

    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
        use super::{create_spec_opportunity, SpecDetails};

        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.to_string(),
                None => continue,
            };

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

            // Extract relation targets from Spec
            let relation_targets = self.extract_relation_targets(ctx, spec_id, &base_type);

            // Check each relation target
            for target_type in relation_targets {
                // Check if struct has field for this relation
                if self.has_field_for_relation(ctx, struct_id, &target_type) {
                    continue;
                }

                // Missing field - create opportunity
                let Some(location) = SuggestLocation::from_context(ctx, struct_id) else {
                    continue;
                };

                let suggested_field = self.suggest_field_name(&target_type);
                let suggested_type = self.suggest_field_type(&target_type);

                let opp = create_spec_opportunity(
                    RULE_CODE,
                    OpportunityId::new(next_id),
                    vec![struct_id, spec_id],
                    location,
                    format!(
                        "Struct `{}` has Spec relation to `{}` but no corresponding field",
                        base_type, target_type
                    ),
                    0.8, // High confidence - spec explicitly declares relation
                    SpecDetails {
                        alias_name: Some(alias_name.to_string()),
                        base_type: Some(base_type.clone()),
                        group: None,
                        related_types: vec![target_type.clone()],
                        suggestion: Some(format!(
                            "Add field `{}: {}`",
                            suggested_field, suggested_type
                        )),
                    },
                );

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

        opportunities
    }

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

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

        let _struct_name = struct_path.name().to_string();

        // Extract related type from opportunity context
        let related_type = match &opportunity.context {
            crate::OpportunityContext::Spec { related_types, .. } => related_types.first().cloned(),
            _ => None,
        };

        let target_type = match related_type {
            Some(t) => t,
            None => return Ok(Vec::new()),
        };

        let field_name = self.suggest_field_name(&target_type);
        let field_type = self.suggest_field_type(&target_type);

        Ok(vec![MutationSpec::AddField {
            target: ryo_executor::MutationTargetSymbol::ById(struct_id),
            field_name,
            field_type,
            visibility: Visibility::Pub,
        }])
    }
}

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

    #[test]
    fn test_suggest_field_name() {
        let rule = SpecRelationToField::new();

        assert_eq!(rule.suggest_field_name("Order"), "order_id");
        assert_eq!(rule.suggest_field_name("User"), "user_id");
        assert_eq!(rule.suggest_field_name("OrderItem"), "order_item_id");
    }

    #[test]
    fn test_suggest_field_type() {
        let rule = SpecRelationToField::new();

        assert_eq!(rule.suggest_field_type("Order"), "OrderId");
        assert_eq!(rule.suggest_field_type("User"), "UserId");
    }

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