ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! Rule storage with scope management
//!
//! Loads and manages lint rules from multiple sources:
//! - Builtin: shipped with the binary
//! - Global: user-defined in `~/.ryo/rules/custom/`
//! - Project: project-local in `<project>/.ryo/rules/`

use ryo_pattern::{LoadError, Rule, RuleLoader};
use std::path::Path;
use thiserror::Error;

/// Scope of rule origin
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuleScope {
    /// Shipped with ryo binary
    Builtin,
    /// User-defined in ~/.ryo/rules/custom/
    Global,
    /// Project-local in <project>/.ryo/rules/
    Project,
}

impl std::fmt::Display for RuleScope {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RuleScope::Builtin => write!(f, "builtin"),
            RuleScope::Global => write!(f, "global"),
            RuleScope::Project => write!(f, "project"),
        }
    }
}

/// Errors from rule store operations
#[derive(Debug, Error)]
pub enum RuleStoreError {
    #[error("Failed to parse rule: {0}")]
    Parse(#[from] LoadError),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Home directory not found")]
    NoHomeDir,
}

/// Storage for pattern-based lint rules
///
/// Rules are loaded from three sources with priority:
/// 1. Project rules (highest priority)
/// 2. Global rules
/// 3. Builtin rules (lowest priority)
///
/// When searching by ID, project rules shadow global rules,
/// which shadow builtin rules.
#[derive(Debug, Default)]
pub struct RuleStore {
    builtin: Vec<Rule>,
    global: Vec<Rule>,
    project: Vec<Rule>,
}

impl RuleStore {
    /// Global rules directory relative to ~/.ryo/
    pub const GLOBAL_RULES_DIR: &'static str = "rules/custom";

    /// Project rules directory relative to project root
    pub const PROJECT_RULES_DIR: &'static str = ".ryo/rules";

    /// Create an empty RuleStore
    pub fn new() -> Self {
        Self::default()
    }

    /// Load rules from all sources
    ///
    /// # Arguments
    /// * `project_path` - Path to the project root directory
    ///
    /// # Example
    /// ```ignore
    /// let store = RuleStore::load(Path::new("/path/to/project"))?;
    /// for rule in store.all_rules() {
    ///     println!("{}: {}", rule.id, rule.name);
    /// }
    /// ```
    pub fn load(project_path: &Path) -> Result<Self, RuleStoreError> {
        let builtin = Self::load_builtin()?;
        let global = Self::load_global()?;
        let project = Self::load_project(project_path)?;

        Ok(Self {
            builtin,
            global,
            project,
        })
    }

    /// Load only builtin rules (for minimal setup)
    pub fn builtin_only() -> Result<Self, RuleStoreError> {
        Ok(Self {
            builtin: Self::load_builtin()?,
            global: vec![],
            project: vec![],
        })
    }

    /// Iterate all rules (builtin → global → project order)
    ///
    /// Later rules can override earlier ones with the same ID.
    /// Use `find_by_id` for priority-aware lookup.
    pub fn all_rules(&self) -> impl Iterator<Item = &Rule> {
        self.builtin
            .iter()
            .chain(self.global.iter())
            .chain(self.project.iter())
    }

    /// Get rules by scope
    pub fn rules_by_scope(&self, scope: RuleScope) -> &[Rule] {
        match scope {
            RuleScope::Builtin => &self.builtin,
            RuleScope::Global => &self.global,
            RuleScope::Project => &self.project,
        }
    }

    /// Find rule by ID (project > global > builtin priority)
    pub fn find_by_id(&self, id: &str) -> Option<&Rule> {
        // Search in reverse priority order (project first)
        self.project
            .iter()
            .chain(self.global.iter())
            .chain(self.builtin.iter())
            .find(|r| r.id == id)
    }

    /// Get total rule count
    pub fn len(&self) -> usize {
        self.builtin.len() + self.global.len() + self.project.len()
    }

    /// Check if store is empty
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Get rule count by scope
    pub fn count_by_scope(&self, scope: RuleScope) -> usize {
        self.rules_by_scope(scope).len()
    }

    /// Load builtin rules (embedded in binary)
    fn load_builtin() -> Result<Vec<Rule>, RuleStoreError> {
        let yaml = include_str!("builtin/default.yaml");
        let rules = RuleLoader::rules_from_yaml(yaml)?;
        Ok(rules)
    }

    /// Load global rules from ~/.ryo/rules/custom/
    fn load_global() -> Result<Vec<Rule>, RuleStoreError> {
        let home = dirs::home_dir().ok_or(RuleStoreError::NoHomeDir)?;
        let rules_dir = home.join(".ryo").join(Self::GLOBAL_RULES_DIR);
        Self::load_from_dir(&rules_dir)
    }

    /// Load project rules from <project>/.ryo/rules/
    fn load_project(project_path: &Path) -> Result<Vec<Rule>, RuleStoreError> {
        let rules_dir = project_path.join(Self::PROJECT_RULES_DIR);
        Self::load_from_dir(&rules_dir)
    }

    /// Load rules from a directory
    ///
    /// Reads all .yaml and .yml files in the directory.
    /// Returns empty vec if directory doesn't exist.
    fn load_from_dir(dir: &Path) -> Result<Vec<Rule>, RuleStoreError> {
        if !dir.exists() {
            return Ok(vec![]);
        }

        let mut rules = Vec::new();

        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();

            // Skip non-YAML files
            let is_yaml = path.extension().is_some_and(|e| e == "yaml" || e == "yml");
            if !is_yaml {
                continue;
            }

            // Skip directories
            if path.is_dir() {
                continue;
            }

            let content = std::fs::read_to_string(&path)?;

            // Try loading as rule list first, then as LintConfig
            match RuleLoader::rules_from_yaml(&content) {
                Ok(loaded) => {
                    rules.extend(loaded);
                }
                Err(_) => {
                    // Try as LintConfig (has inline_rules field)
                    if let Ok(config) = RuleLoader::from_yaml(&content) {
                        rules.extend(config.inline_rules);
                    }
                    // Silently skip files that don't parse
                    // (could be config files, not rule files)
                }
            }
        }

        Ok(rules)
    }
}

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

    #[test]
    fn test_load_builtin() {
        let rules = RuleStore::load_builtin().unwrap();
        assert!(!rules.is_empty(), "Should have builtin rules");

        // Check first rule has required fields
        let first = &rules[0];
        assert!(!first.id.is_empty());
        assert!(!first.name.is_empty());
    }

    #[test]
    fn test_builtin_only() {
        let store = RuleStore::builtin_only().unwrap();
        assert!(!store.is_empty());
        assert!(!store.rules_by_scope(RuleScope::Builtin).is_empty());
        assert!(store.rules_by_scope(RuleScope::Global).is_empty());
        assert!(store.rules_by_scope(RuleScope::Project).is_empty());
    }

    #[test]
    fn test_load_from_nonexistent_dir() {
        let rules = RuleStore::load_from_dir(Path::new("/nonexistent/path")).unwrap();
        assert!(rules.is_empty());
    }

    #[test]
    fn test_load_project_rules() {
        let temp = TempDir::new().unwrap();
        let rules_dir = temp.path().join(".ryo/rules");
        std::fs::create_dir_all(&rules_dir).unwrap();

        // Write a test rule file
        let rule_yaml = r#"
- id: "TEST001"
  name: "test-rule"
  severity: Warning
  query:
    kind: Function
  message: "Test message"
"#;
        std::fs::write(rules_dir.join("test.yaml"), rule_yaml).unwrap();

        let store = RuleStore::load(temp.path()).unwrap();

        // Should have project rules
        assert!(
            !store.rules_by_scope(RuleScope::Project).is_empty(),
            "Should have project rules"
        );

        // Should find by ID
        let rule = store.find_by_id("TEST001");
        assert!(rule.is_some());
        assert_eq!(rule.unwrap().name, "test-rule");
    }

    #[test]
    fn test_find_by_id_priority() {
        let temp = TempDir::new().unwrap();
        let rules_dir = temp.path().join(".ryo/rules");
        std::fs::create_dir_all(&rules_dir).unwrap();

        // Write a project rule that shadows a builtin rule
        // (assuming builtin has RL001)
        let rule_yaml = r#"
- id: "RL001"
  name: "project-override"
  severity: Error
  query:
    kind: Function
  message: "Project override message"
"#;
        std::fs::write(rules_dir.join("override.yaml"), rule_yaml).unwrap();

        let store = RuleStore::load(temp.path()).unwrap();

        // Project rule should shadow builtin
        let rule = store.find_by_id("RL001").unwrap();
        assert_eq!(rule.name, "project-override");
    }

    #[test]
    fn test_rule_scope_display() {
        assert_eq!(format!("{}", RuleScope::Builtin), "builtin");
        assert_eq!(format!("{}", RuleScope::Global), "global");
        assert_eq!(format!("{}", RuleScope::Project), "project");
    }
}