Skip to main content

ryo_suggest/spec/
relation_to_field.rs

1//! SpecRelationToField - Suggests struct fields based on Spec relations
2//!
3//! This rule analyzes Spec relations to suggest missing struct fields.
4
5use ryo_analysis::context::AnalysisContext;
6use ryo_analysis::{SymbolId, SymbolKind};
7
8use super::{is_framework_type, SpecSuggest};
9use crate::{
10    MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestLocation,
11    SuggestOpportunity, SuggestResult,
12};
13use ryo_executor::Visibility;
14
15/// SpecRelationToField rule
16///
17/// Analyzes Spec relations (DependsOn, RelatedTo, PartOf) to suggest
18/// corresponding struct fields that should exist.
19///
20/// # Rule Code
21/// RS007 (Ryo Spec)
22///
23/// # Detection
24/// 1. Find Spec TypeAliases with relations (DependsOn, RelatedTo, PartOf)
25/// 2. Get the wrapped struct type
26/// 3. Check if struct has fields referencing the relation target
27/// 4. Suggest AddField if missing
28///
29/// # Example
30/// ```ignore
31/// type UserSpec = Spec<DomainGroup, User, [DependsOn<Order>]>;
32///
33/// pub struct User {
34///     id: UserId,
35///     name: String,
36///     // Missing: order field!
37/// }
38///
39/// // Suggestion: Add `order_id: OrderId` or `orders: Vec<Order>` field
40/// ```
41///
42/// # Fix
43/// Generates `AddField` MutationSpec to add the missing field.
44pub struct SpecRelationToField {
45    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
46    suffix: String,
47}
48
49impl SpecRelationToField {
50    pub fn new() -> Self {
51        Self {
52            suffix: "Spec".to_string(),
53        }
54    }
55
56    /// Create with custom suffix pattern
57    pub fn with_suffix(suffix: impl Into<String>) -> Self {
58        Self {
59            suffix: suffix.into(),
60        }
61    }
62
63    /// Find the struct for a given type name
64    fn find_struct_for_type(&self, ctx: &AnalysisContext, type_name: &str) -> Option<SymbolId> {
65        for symbol_id in ctx.registry.iter_by_kind(SymbolKind::Struct) {
66            if let Some(path) = ctx.registry.path(symbol_id) {
67                if path.name() == type_name {
68                    return Some(symbol_id);
69                }
70            }
71        }
72        None
73    }
74
75    /// Get field names of a struct
76    fn get_struct_fields(&self, ctx: &AnalysisContext, struct_id: SymbolId) -> Vec<String> {
77        let graph = ctx.code_graph();
78        let mut fields = Vec::new();
79
80        for child_id in graph.children_of(struct_id) {
81            if let Some(SymbolKind::Field) = ctx.registry.kind(child_id) {
82                if let Some(path) = ctx.registry.path(child_id) {
83                    fields.push(path.name().to_string());
84                }
85            }
86        }
87
88        fields
89    }
90
91    /// Get field types of a struct (types used by struct fields)
92    fn get_struct_field_types(&self, ctx: &AnalysisContext, struct_id: SymbolId) -> Vec<String> {
93        let typeflow = ctx.typeflow_graph();
94        let mut types = Vec::new();
95
96        // Types used by struct fields (via TypeFlow)
97        for used_id in typeflow.types_used_by(struct_id) {
98            if let Some(path) = ctx.registry.path(used_id) {
99                let kind = ctx.registry.kind(used_id);
100                if matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
101                    types.push(path.name().to_string());
102                }
103            }
104        }
105
106        types
107    }
108
109    /// Extract types referenced by a Spec TypeAlias (relation targets)
110    fn extract_relation_targets(
111        &self,
112        ctx: &AnalysisContext,
113        spec_id: SymbolId,
114        base_type: &str,
115    ) -> Vec<String> {
116        let typeflow = ctx.typeflow_graph();
117        let mut targets = Vec::new();
118
119        for used_id in typeflow.types_used_by(spec_id) {
120            if let Some(path) = ctx.registry.path(used_id) {
121                let kind = ctx.registry.kind(used_id);
122
123                // Only consider structs and enums
124                if !matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
125                    continue;
126                }
127
128                let used_name = path.name();
129
130                // Skip framework types, self-references, and the base type
131                if is_framework_type(used_name) || used_name == base_type {
132                    continue;
133                }
134
135                targets.push(used_name.to_string());
136            }
137        }
138
139        targets
140    }
141
142    /// Check if struct has a field referencing the target type
143    fn has_field_for_relation(
144        &self,
145        ctx: &AnalysisContext,
146        struct_id: SymbolId,
147        target_type: &str,
148    ) -> bool {
149        let fields = self.get_struct_fields(ctx, struct_id);
150        let field_types = self.get_struct_field_types(ctx, struct_id);
151
152        // Check if any field name contains the target (e.g., "order_id", "orders")
153        let target_lower = target_type.to_lowercase();
154        let has_field_name = fields
155            .iter()
156            .any(|f| f.to_lowercase().contains(&target_lower));
157
158        // Check if any field type references the target
159        let has_field_type = field_types.iter().any(|t| {
160            t == target_type || t == &format!("{}Id", target_type) || t.contains(target_type)
161        });
162
163        has_field_name || has_field_type
164    }
165
166    /// Generate field name suggestion for a relation target
167    fn suggest_field_name(&self, target_type: &str) -> String {
168        // Convert PascalCase to snake_case and add _id suffix
169        let mut result = String::new();
170        for (i, c) in target_type.chars().enumerate() {
171            if c.is_uppercase() && i > 0 {
172                result.push('_');
173            }
174            result.push(c.to_ascii_lowercase());
175        }
176        format!("{}_id", result)
177    }
178
179    /// Generate field type suggestion for a relation target
180    fn suggest_field_type(&self, target_type: &str) -> String {
181        format!("{}Id", target_type)
182    }
183}
184
185impl Default for SpecRelationToField {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// Rule code for SpecRelationToField
192const RULE_CODE: &str = "RS007";
193
194impl SpecSuggest for SpecRelationToField {
195    fn spec_suffix(&self) -> &str {
196        &self.suffix
197    }
198}
199
200impl Suggest for SpecRelationToField {
201    fn name(&self) -> &'static str {
202        "spec-relation-to-field"
203    }
204
205    fn description(&self) -> &str {
206        "Suggests struct fields based on Spec relations"
207    }
208
209    fn category(&self) -> SuggestCategory {
210        SuggestCategory::Pattern
211    }
212
213    fn safety_level(&self) -> SafetyLevel {
214        SafetyLevel::Confirm // Suggestions need user confirmation
215    }
216
217    fn priority_weight(&self) -> f32 {
218        1.0 // Medium priority
219    }
220
221    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
222        use super::{create_spec_opportunity, SpecDetails};
223
224        let mut opportunities = Vec::new();
225        let mut next_id = 0u32;
226
227        // Get all TypeAliases to check
228        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
229            ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
230        } else {
231            symbols.to_vec()
232        };
233
234        for spec_id in symbols_to_check {
235            let path = match ctx.registry.path(spec_id) {
236                Some(p) => p,
237                None => continue,
238            };
239
240            let alias_name = path.name();
241
242            // Check if this looks like a Spec TypeAlias
243            if !self.is_spec_alias(alias_name) {
244                continue;
245            }
246
247            // Extract base type name
248            let base_type = match self.extract_base_type(alias_name) {
249                Some(bt) => bt.to_string(),
250                None => continue,
251            };
252
253            // Find the corresponding struct
254            let struct_id = match self.find_struct_for_type(ctx, &base_type) {
255                Some(id) => id,
256                None => continue,
257            };
258
259            // Extract relation targets from Spec
260            let relation_targets = self.extract_relation_targets(ctx, spec_id, &base_type);
261
262            // Check each relation target
263            for target_type in relation_targets {
264                // Check if struct has field for this relation
265                if self.has_field_for_relation(ctx, struct_id, &target_type) {
266                    continue;
267                }
268
269                // Missing field - create opportunity
270                let Some(location) = SuggestLocation::from_context(ctx, struct_id) else {
271                    continue;
272                };
273
274                let suggested_field = self.suggest_field_name(&target_type);
275                let suggested_type = self.suggest_field_type(&target_type);
276
277                let opp = create_spec_opportunity(
278                    RULE_CODE,
279                    OpportunityId::new(next_id),
280                    vec![struct_id, spec_id],
281                    location,
282                    format!(
283                        "Struct `{}` has Spec relation to `{}` but no corresponding field",
284                        base_type, target_type
285                    ),
286                    0.8, // High confidence - spec explicitly declares relation
287                    SpecDetails {
288                        alias_name: Some(alias_name.to_string()),
289                        base_type: Some(base_type.clone()),
290                        group: None,
291                        related_types: vec![target_type.clone()],
292                        suggestion: Some(format!(
293                            "Add field `{}: {}`",
294                            suggested_field, suggested_type
295                        )),
296                    },
297                );
298
299                opportunities.push(opp);
300                next_id += 1;
301            }
302        }
303
304        opportunities
305    }
306
307    fn to_mutation_specs(
308        &self,
309        ctx: &AnalysisContext,
310        opportunity: &SuggestOpportunity,
311    ) -> SuggestResult<Vec<MutationSpec>> {
312        // Get the struct symbol
313        let struct_id = match opportunity.targets.first() {
314            Some(id) => *id,
315            None => return Ok(Vec::new()),
316        };
317
318        let struct_path = match ctx.registry.path(struct_id) {
319            Some(p) => p,
320            None => return Ok(Vec::new()),
321        };
322
323        let _struct_name = struct_path.name().to_string();
324
325        // Extract related type from opportunity context
326        let related_type = match &opportunity.context {
327            crate::OpportunityContext::Spec { related_types, .. } => related_types.first().cloned(),
328            _ => None,
329        };
330
331        let target_type = match related_type {
332            Some(t) => t,
333            None => return Ok(Vec::new()),
334        };
335
336        let field_name = self.suggest_field_name(&target_type);
337        let field_type = self.suggest_field_type(&target_type);
338
339        Ok(vec![MutationSpec::AddField {
340            target: ryo_executor::MutationTargetSymbol::ById(struct_id),
341            field_name,
342            field_type,
343            visibility: Visibility::Pub,
344        }])
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_suggest_field_name() {
354        let rule = SpecRelationToField::new();
355
356        assert_eq!(rule.suggest_field_name("Order"), "order_id");
357        assert_eq!(rule.suggest_field_name("User"), "user_id");
358        assert_eq!(rule.suggest_field_name("OrderItem"), "order_item_id");
359    }
360
361    #[test]
362    fn test_suggest_field_type() {
363        let rule = SpecRelationToField::new();
364
365        assert_eq!(rule.suggest_field_type("Order"), "OrderId");
366        assert_eq!(rule.suggest_field_type("User"), "UserId");
367    }
368
369    #[test]
370    fn test_is_spec_alias() {
371        let rule = SpecRelationToField::new();
372
373        assert!(rule.is_spec_alias("TaskSpec"));
374        assert!(rule.is_spec_alias("UserSpec"));
375        assert!(!rule.is_spec_alias("Spec"));
376        assert!(!rule.is_spec_alias("Task"));
377    }
378}