ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! Generator storage with scope management
//!
//! Loads and manages generator templates from multiple sources:
//! - Global: user-defined in `~/.ryo/generators/`
//! - Project: project-local in `<project>/.ryo/generators/`

use ryo_pattern::{GeneratorLoader, GeneratorTemplate};
use std::path::Path;
use thiserror::Error;

/// Scope of generator origin
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GeneratorScope {
    /// User-defined in ~/.ryo/generators/
    Global,
    /// Project-local in <project>/.ryo/generators/
    Project,
}

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

/// Errors from generator store operations
#[derive(Debug, Error)]
pub enum GeneratorStoreError {
    #[error("Failed to load generator: {0}")]
    Load(#[from] ryo_pattern::GeneratorLoadError),

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

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

/// Entry in the generator store
#[derive(Debug, Clone)]
pub struct GeneratorEntry {
    /// The template
    pub template: GeneratorTemplate,
    /// Where this template came from
    pub scope: GeneratorScope,
}

/// Storage for generator templates
///
/// Templates are loaded from two sources with priority:
/// 1. Project templates (highest priority)
/// 2. Global templates (lowest priority)
///
/// When searching by ID/name, project templates shadow global templates.
#[derive(Debug, Default)]
pub struct GeneratorStore {
    global: Vec<GeneratorEntry>,
    project: Vec<GeneratorEntry>,
}

impl GeneratorStore {
    /// Global generators directory relative to ~/.ryo/
    pub const GLOBAL_GENERATORS_DIR: &'static str = "generators";

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

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

    /// Load generators from all sources
    ///
    /// # Arguments
    /// * `project_path` - Path to the project root directory
    pub fn load(project_path: &Path) -> Result<Self, GeneratorStoreError> {
        let global = Self::load_global()?;
        let project = Self::load_project(project_path)?;

        Ok(Self { global, project })
    }

    /// Load only global generators
    pub fn global_only() -> Result<Self, GeneratorStoreError> {
        Ok(Self {
            global: Self::load_global()?,
            project: Vec::new(),
        })
    }

    /// Load global generators from ~/.ryo/generators/
    fn load_global() -> Result<Vec<GeneratorEntry>, GeneratorStoreError> {
        let home = dirs::home_dir().ok_or(GeneratorStoreError::NoHomeDir)?;
        let global_dir = home.join(".ryo").join(Self::GLOBAL_GENERATORS_DIR);

        if !global_dir.exists() {
            return Ok(Vec::new());
        }

        Self::load_from_dir(&global_dir, GeneratorScope::Global)
    }

    /// Load project-local generators from <project>/.ryo/generators/
    fn load_project(project_path: &Path) -> Result<Vec<GeneratorEntry>, GeneratorStoreError> {
        let project_dir = project_path.join(Self::PROJECT_GENERATORS_DIR);

        if !project_dir.exists() {
            return Ok(Vec::new());
        }

        Self::load_from_dir(&project_dir, GeneratorScope::Project)
    }

    /// Load all generator files from a directory
    fn load_from_dir(
        dir: &Path,
        scope: GeneratorScope,
    ) -> Result<Vec<GeneratorEntry>, GeneratorStoreError> {
        let templates = GeneratorLoader::load_dir(dir)?;
        Ok(templates
            .into_iter()
            .map(|template| GeneratorEntry { template, scope })
            .collect())
    }

    /// Find a generator by ID (project shadows global)
    pub fn find_by_id(&self, id: &str) -> Option<&GeneratorEntry> {
        // Project first (higher priority)
        if let Some(entry) = self.project.iter().find(|e| e.template.id() == id) {
            return Some(entry);
        }

        // Then global
        self.global.iter().find(|e| e.template.id() == id)
    }

    /// Find a generator by name (project shadows global)
    pub fn find_by_name(&self, name: &str) -> Option<&GeneratorEntry> {
        // Project first (higher priority)
        if let Some(entry) = self.project.iter().find(|e| e.template.name() == name) {
            return Some(entry);
        }

        // Then global
        self.global.iter().find(|e| e.template.name() == name)
    }

    /// Get all generators (project entries shadow global with same ID)
    pub fn all_generators(&self) -> impl Iterator<Item = &GeneratorEntry> {
        // Collect project IDs for shadowing
        let project_ids: std::collections::HashSet<_> =
            self.project.iter().map(|e| e.template.id()).collect();

        // Project generators + non-shadowed global generators
        self.project.iter().chain(
            self.global
                .iter()
                .filter(move |e| !project_ids.contains(e.template.id())),
        )
    }

    /// Get number of generators
    pub fn len(&self) -> usize {
        // Count unique IDs (project shadows global)
        let project_ids: std::collections::HashSet<_> =
            self.project.iter().map(|e| e.template.id()).collect();

        let global_unique = self
            .global
            .iter()
            .filter(|e| !project_ids.contains(e.template.id()))
            .count();

        self.project.len() + global_unique
    }

    /// Check if store is empty
    pub fn is_empty(&self) -> bool {
        self.global.is_empty() && self.project.is_empty()
    }

    /// List all generator names with their scope
    pub fn list_names(&self) -> Vec<(&str, GeneratorScope)> {
        self.all_generators()
            .map(|e| (e.template.name(), e.scope))
            .collect()
    }
}

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

    fn create_test_generator(dir: &Path, name: &str) {
        let content = format!(
            r#"
generator:
  id: "{name}"
  name: "{name}"
  description: Test generator

params:
  - name: value
    description: Test param
    required: true

template:
  code: "// Generated: {{{{value}}}}"
"#
        );

        let path = dir.join(format!("{}.yaml", name));
        let mut file = std::fs::File::create(path).unwrap();
        file.write_all(content.as_bytes()).unwrap();
    }

    #[test]
    fn test_load_from_dir() {
        let temp = TempDir::new().unwrap();
        create_test_generator(temp.path(), "test_gen");

        let entries = GeneratorStore::load_from_dir(temp.path(), GeneratorScope::Project).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].template.name(), "test_gen");
    }

    #[test]
    fn test_find_by_name() {
        let temp = TempDir::new().unwrap();
        let gen_dir = temp.path().join(".ryo/generators");
        std::fs::create_dir_all(&gen_dir).unwrap();
        create_test_generator(&gen_dir, "my_generator");

        let store = GeneratorStore::load(temp.path()).unwrap();
        let entry = store.find_by_name("my_generator");
        assert!(entry.is_some());
        assert_eq!(entry.unwrap().scope, GeneratorScope::Project);
    }

    #[test]
    fn test_empty_store() {
        let store = GeneratorStore::new();
        assert!(store.is_empty());
        assert_eq!(store.len(), 0);
    }
}