ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! MissingSpecForDomainType - Detects domain types without Spec TypeAlias
//!
//! This rule checks that domain model types have corresponding Spec TypeAliases.

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

use super::SpecSuggest;
use crate::lint::{LintDetails, LintSuggest};
use crate::{
    LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestError,
    SuggestLocation, SuggestOpportunity, SuggestResult,
};

/// MissingSpecForDomainType rule
///
/// Detects struct/enum types in domain modules that lack corresponding Spec TypeAliases.
///
/// # Rule Code
/// RS001 (Ryo Spec)
///
/// # Detection
/// 1. Find struct/enum in `domain/`, `model/`, or similar modules
/// 2. Check if there's a corresponding `*Spec` TypeAlias
/// 3. Report if missing
///
/// # Example Violation
/// ```ignore
/// // src/domain/task.rs
/// pub struct Task { ... }
/// // WARNING: No TaskSpec TypeAlias found
/// ```
///
/// # Fix
/// Generates `AddSpec` MutationSpec to create the TypeAlias.
pub struct MissingSpecForDomainType {
    /// Module path patterns that indicate domain types (e.g., "domain", "model")
    domain_patterns: Vec<String>,
    /// Default group name for generated Specs
    default_group: String,
}

impl MissingSpecForDomainType {
    pub fn new() -> Self {
        Self {
            domain_patterns: vec![
                "domain".to_string(),
                "model".to_string(),
                "entity".to_string(),
                "aggregate".to_string(),
            ],
            default_group: "DomainGroup".to_string(),
        }
    }

    /// Create with custom domain patterns
    pub fn with_patterns(patterns: Vec<String>) -> Self {
        Self {
            domain_patterns: patterns,
            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
    }

    /// Check if a symbol path is in a domain module
    fn is_domain_module(&self, path: &SymbolPath) -> bool {
        let path_str = path.to_string().to_lowercase();
        self.domain_patterns
            .iter()
            .any(|pattern| path_str.contains(&pattern.to_lowercase()))
    }

    /// Check if a Spec TypeAlias exists for the given type
    fn has_spec_alias(&self, ctx: &AnalysisContext, type_name: &str) -> bool {
        let spec_name = format!("{}Spec", type_name);

        // Search for TypeAlias with matching name
        for (id, path) in ctx.registry.iter() {
            if let Some(SymbolKind::TypeAlias) = ctx.registry.kind(id) {
                if path.name() == spec_name {
                    return true;
                }
            }
        }

        false
    }
}

impl SpecSuggest for MissingSpecForDomainType {}

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

impl Suggest for MissingSpecForDomainType {
    fn name(&self) -> &'static str {
        "missing-spec-for-domain-type"
    }

    fn description(&self) -> &str {
        "Detects domain model types that lack corresponding Spec TypeAliases"
    }

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

    fn safety_level(&self) -> SafetyLevel {
        SafetyLevel::Confirm // Needs user confirmation before adding
    }

    fn priority_weight(&self) -> f32 {
        1.5 // Medium-high priority for domain consistency
    }

    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
        let mut opportunities = Vec::new();
        let mut next_id = 0u32;

        // If specific symbols provided, check only those
        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
            // Check all Struct and Enum symbols
            ctx.registry
                .iter_by_kind(SymbolKind::Struct)
                .chain(ctx.registry.iter_by_kind(SymbolKind::Enum))
                .collect()
        } else {
            symbols.to_vec()
        };

        for symbol_id in symbols_to_check {
            let path = match ctx.registry.path(symbol_id) {
                Some(p) => p,
                None => continue,
            };

            // Check if this is a domain type
            if !self.is_domain_module(path) {
                continue;
            }

            let type_name = path.name();

            // Skip if already has Spec
            if self.has_spec_alias(ctx, type_name) {
                continue;
            }

            // Skip internal/helper types (lowercase or underscore prefix)
            if type_name.starts_with('_')
                || type_name.chars().next().is_none_or(|c| c.is_lowercase())
            {
                continue;
            }

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

            let opp = self.create_lint_opportunity(
                OpportunityId::new(next_id),
                vec![symbol_id],
                location,
                format!("Domain type `{}` has no Spec TypeAlias", type_name),
                LintDetails {
                    suggestion: Some(format!(
                        "Add `type {}Spec = Spec<{}, {}>;`",
                        type_name, self.default_group, type_name
                    )),
                    expected: Some(format!("type {}Spec = Spec<...>;", type_name)),
                    actual: Some("No Spec found".to_string()),
                },
            );

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

        opportunities
    }

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

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

        let type_name = path.name().to_string();
        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()),
        };

        Ok(vec![MutationSpec::AddSpec {
            type_id: symbol_id,
            module_id,
            group: self.default_group.clone(),
            alias_name: Some(format!("{}Spec", type_name)),
            relations: Vec::new(), // No relations by default
        }])
    }
}

impl LintSuggest for MissingSpecForDomainType {
    fn code(&self) -> &'static str {
        "RS001"
    }

    fn default_severity(&self) -> LintSeverity {
        LintSeverity::Warning
    }
}

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

    #[test]
    fn test_is_domain_module() {
        let rule = MissingSpecForDomainType::new();

        let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
        assert!(rule.is_domain_module(&domain_path));

        let model_path = SymbolPath::parse("test_crate::model::user::User").unwrap();
        assert!(rule.is_domain_module(&model_path));

        let entity_path = SymbolPath::parse("test_crate::entity::order::Order").unwrap();
        assert!(rule.is_domain_module(&entity_path));

        let util_path = SymbolPath::parse("test_crate::util::helper::Helper").unwrap();
        assert!(!rule.is_domain_module(&util_path));
    }

    #[test]
    fn test_custom_patterns() {
        let rule = MissingSpecForDomainType::with_patterns(vec!["core".to_string()]);

        let core_path = SymbolPath::parse("test_crate::core::config::Config").unwrap();
        assert!(rule.is_domain_module(&core_path));

        let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
        assert!(!rule.is_domain_module(&domain_path));
    }

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