1use 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
15pub struct SpecRelationToField {
45 suffix: String,
47}
48
49impl SpecRelationToField {
50 pub fn new() -> Self {
51 Self {
52 suffix: "Spec".to_string(),
53 }
54 }
55
56 pub fn with_suffix(suffix: impl Into<String>) -> Self {
58 Self {
59 suffix: suffix.into(),
60 }
61 }
62
63 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 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 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 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 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 if !matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
125 continue;
126 }
127
128 let used_name = path.name();
129
130 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 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 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 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 fn suggest_field_name(&self, target_type: &str) -> String {
168 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 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
191const 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 }
216
217 fn priority_weight(&self) -> f32 {
218 1.0 }
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 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 if !self.is_spec_alias(alias_name) {
244 continue;
245 }
246
247 let base_type = match self.extract_base_type(alias_name) {
249 Some(bt) => bt.to_string(),
250 None => continue,
251 };
252
253 let struct_id = match self.find_struct_for_type(ctx, &base_type) {
255 Some(id) => id,
256 None => continue,
257 };
258
259 let relation_targets = self.extract_relation_targets(ctx, spec_id, &base_type);
261
262 for target_type in relation_targets {
264 if self.has_field_for_relation(ctx, struct_id, &target_type) {
266 continue;
267 }
268
269 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, 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 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 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}