Skip to main content

ryo_suggest/spec/
missing_spec.rs

1//! MissingSpecForDomainType - Detects domain types without Spec TypeAlias
2//!
3//! This rule checks that domain model types have corresponding Spec TypeAliases.
4
5use ryo_analysis::context::AnalysisContext;
6use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
7
8use super::SpecSuggest;
9use crate::lint::{LintDetails, LintSuggest};
10use crate::{
11    LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestError,
12    SuggestLocation, SuggestOpportunity, SuggestResult,
13};
14
15/// MissingSpecForDomainType rule
16///
17/// Detects struct/enum types in domain modules that lack corresponding Spec TypeAliases.
18///
19/// # Rule Code
20/// RS001 (Ryo Spec)
21///
22/// # Detection
23/// 1. Find struct/enum in `domain/`, `model/`, or similar modules
24/// 2. Check if there's a corresponding `*Spec` TypeAlias
25/// 3. Report if missing
26///
27/// # Example Violation
28/// ```ignore
29/// // src/domain/task.rs
30/// pub struct Task { ... }
31/// // WARNING: No TaskSpec TypeAlias found
32/// ```
33///
34/// # Fix
35/// Generates `AddSpec` MutationSpec to create the TypeAlias.
36pub struct MissingSpecForDomainType {
37    /// Module path patterns that indicate domain types (e.g., "domain", "model")
38    domain_patterns: Vec<String>,
39    /// Default group name for generated Specs
40    default_group: String,
41}
42
43impl MissingSpecForDomainType {
44    pub fn new() -> Self {
45        Self {
46            domain_patterns: vec![
47                "domain".to_string(),
48                "model".to_string(),
49                "entity".to_string(),
50                "aggregate".to_string(),
51            ],
52            default_group: "DomainGroup".to_string(),
53        }
54    }
55
56    /// Create with custom domain patterns
57    pub fn with_patterns(patterns: Vec<String>) -> Self {
58        Self {
59            domain_patterns: patterns,
60            default_group: "DomainGroup".to_string(),
61        }
62    }
63
64    /// Set the default group name
65    pub fn with_group(mut self, group: impl Into<String>) -> Self {
66        self.default_group = group.into();
67        self
68    }
69
70    /// Check if a symbol path is in a domain module
71    fn is_domain_module(&self, path: &SymbolPath) -> bool {
72        let path_str = path.to_string().to_lowercase();
73        self.domain_patterns
74            .iter()
75            .any(|pattern| path_str.contains(&pattern.to_lowercase()))
76    }
77
78    /// Check if a Spec TypeAlias exists for the given type
79    fn has_spec_alias(&self, ctx: &AnalysisContext, type_name: &str) -> bool {
80        let spec_name = format!("{}Spec", type_name);
81
82        // Search for TypeAlias with matching name
83        for (id, path) in ctx.registry.iter() {
84            if let Some(SymbolKind::TypeAlias) = ctx.registry.kind(id) {
85                if path.name() == spec_name {
86                    return true;
87                }
88            }
89        }
90
91        false
92    }
93}
94
95impl SpecSuggest for MissingSpecForDomainType {}
96
97impl Default for MissingSpecForDomainType {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl Suggest for MissingSpecForDomainType {
104    fn name(&self) -> &'static str {
105        "missing-spec-for-domain-type"
106    }
107
108    fn description(&self) -> &str {
109        "Detects domain model types that lack corresponding Spec TypeAliases"
110    }
111
112    fn category(&self) -> SuggestCategory {
113        SuggestCategory::Lint
114    }
115
116    fn safety_level(&self) -> SafetyLevel {
117        SafetyLevel::Confirm // Needs user confirmation before adding
118    }
119
120    fn priority_weight(&self) -> f32 {
121        1.5 // Medium-high priority for domain consistency
122    }
123
124    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
125        let mut opportunities = Vec::new();
126        let mut next_id = 0u32;
127
128        // If specific symbols provided, check only those
129        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
130            // Check all Struct and Enum symbols
131            ctx.registry
132                .iter_by_kind(SymbolKind::Struct)
133                .chain(ctx.registry.iter_by_kind(SymbolKind::Enum))
134                .collect()
135        } else {
136            symbols.to_vec()
137        };
138
139        for symbol_id in symbols_to_check {
140            let path = match ctx.registry.path(symbol_id) {
141                Some(p) => p,
142                None => continue,
143            };
144
145            // Check if this is a domain type
146            if !self.is_domain_module(path) {
147                continue;
148            }
149
150            let type_name = path.name();
151
152            // Skip if already has Spec
153            if self.has_spec_alias(ctx, type_name) {
154                continue;
155            }
156
157            // Skip internal/helper types (lowercase or underscore prefix)
158            if type_name.starts_with('_')
159                || type_name.chars().next().is_none_or(|c| c.is_lowercase())
160            {
161                continue;
162            }
163
164            // Get location
165            let Some(location) = SuggestLocation::from_context(ctx, symbol_id) else {
166                continue;
167            };
168
169            let opp = self.create_lint_opportunity(
170                OpportunityId::new(next_id),
171                vec![symbol_id],
172                location,
173                format!("Domain type `{}` has no Spec TypeAlias", type_name),
174                LintDetails {
175                    suggestion: Some(format!(
176                        "Add `type {}Spec = Spec<{}, {}>;`",
177                        type_name, self.default_group, type_name
178                    )),
179                    expected: Some(format!("type {}Spec = Spec<...>;", type_name)),
180                    actual: Some("No Spec found".to_string()),
181                },
182            );
183
184            opportunities.push(opp);
185            next_id += 1;
186        }
187
188        opportunities
189    }
190
191    fn to_mutation_specs(
192        &self,
193        ctx: &AnalysisContext,
194        opportunity: &SuggestOpportunity,
195    ) -> SuggestResult<Vec<MutationSpec>> {
196        // Get the target symbol
197        let symbol_id = match opportunity.targets.first() {
198            Some(id) => *id,
199            None => return Ok(Vec::new()),
200        };
201
202        let path = match ctx.registry.path(symbol_id) {
203            Some(p) => p,
204            None => return Ok(Vec::new()),
205        };
206
207        let type_name = path.name().to_string();
208        let module_path =
209            self.get_module_path(path)
210                .ok_or_else(|| SuggestError::ModulePathResolution {
211                    path: path.to_string(),
212                })?;
213
214        // Resolve module_id from module_path
215        let module_id = match ctx.registry.lookup(&module_path) {
216            Some(id) => id,
217            None => return Ok(Vec::new()),
218        };
219
220        Ok(vec![MutationSpec::AddSpec {
221            type_id: symbol_id,
222            module_id,
223            group: self.default_group.clone(),
224            alias_name: Some(format!("{}Spec", type_name)),
225            relations: Vec::new(), // No relations by default
226        }])
227    }
228}
229
230impl LintSuggest for MissingSpecForDomainType {
231    fn code(&self) -> &'static str {
232        "RS001"
233    }
234
235    fn default_severity(&self) -> LintSeverity {
236        LintSeverity::Warning
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_is_domain_module() {
246        let rule = MissingSpecForDomainType::new();
247
248        let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
249        assert!(rule.is_domain_module(&domain_path));
250
251        let model_path = SymbolPath::parse("test_crate::model::user::User").unwrap();
252        assert!(rule.is_domain_module(&model_path));
253
254        let entity_path = SymbolPath::parse("test_crate::entity::order::Order").unwrap();
255        assert!(rule.is_domain_module(&entity_path));
256
257        let util_path = SymbolPath::parse("test_crate::util::helper::Helper").unwrap();
258        assert!(!rule.is_domain_module(&util_path));
259    }
260
261    #[test]
262    fn test_custom_patterns() {
263        let rule = MissingSpecForDomainType::with_patterns(vec!["core".to_string()]);
264
265        let core_path = SymbolPath::parse("test_crate::core::config::Config").unwrap();
266        assert!(rule.is_domain_module(&core_path));
267
268        let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
269        assert!(!rule.is_domain_module(&domain_path));
270    }
271
272    #[test]
273    fn test_with_group() {
274        let rule = MissingSpecForDomainType::new().with_group("MyGroup");
275        assert_eq!(rule.default_group, "MyGroup");
276    }
277}