Skip to main content

ryo_suggest/generator/
store.rs

1//! Generator storage with scope management
2//!
3//! Loads and manages generator templates from multiple sources:
4//! - Global: user-defined in `~/.ryo/generators/`
5//! - Project: project-local in `<project>/.ryo/generators/`
6
7use ryo_pattern::{GeneratorLoader, GeneratorTemplate};
8use std::path::Path;
9use thiserror::Error;
10
11/// Scope of generator origin
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum GeneratorScope {
14    /// User-defined in ~/.ryo/generators/
15    Global,
16    /// Project-local in <project>/.ryo/generators/
17    Project,
18}
19
20impl std::fmt::Display for GeneratorScope {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            GeneratorScope::Global => write!(f, "global"),
24            GeneratorScope::Project => write!(f, "project"),
25        }
26    }
27}
28
29/// Errors from generator store operations
30#[derive(Debug, Error)]
31pub enum GeneratorStoreError {
32    #[error("Failed to load generator: {0}")]
33    Load(#[from] ryo_pattern::GeneratorLoadError),
34
35    #[error("IO error: {0}")]
36    Io(#[from] std::io::Error),
37
38    #[error("Home directory not found")]
39    NoHomeDir,
40}
41
42/// Entry in the generator store
43#[derive(Debug, Clone)]
44pub struct GeneratorEntry {
45    /// The template
46    pub template: GeneratorTemplate,
47    /// Where this template came from
48    pub scope: GeneratorScope,
49}
50
51/// Storage for generator templates
52///
53/// Templates are loaded from two sources with priority:
54/// 1. Project templates (highest priority)
55/// 2. Global templates (lowest priority)
56///
57/// When searching by ID/name, project templates shadow global templates.
58#[derive(Debug, Default)]
59pub struct GeneratorStore {
60    global: Vec<GeneratorEntry>,
61    project: Vec<GeneratorEntry>,
62}
63
64impl GeneratorStore {
65    /// Global generators directory relative to ~/.ryo/
66    pub const GLOBAL_GENERATORS_DIR: &'static str = "generators";
67
68    /// Project generators directory relative to project root
69    pub const PROJECT_GENERATORS_DIR: &'static str = ".ryo/generators";
70
71    /// Create an empty GeneratorStore
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Load generators from all sources
77    ///
78    /// # Arguments
79    /// * `project_path` - Path to the project root directory
80    pub fn load(project_path: &Path) -> Result<Self, GeneratorStoreError> {
81        let global = Self::load_global()?;
82        let project = Self::load_project(project_path)?;
83
84        Ok(Self { global, project })
85    }
86
87    /// Load only global generators
88    pub fn global_only() -> Result<Self, GeneratorStoreError> {
89        Ok(Self {
90            global: Self::load_global()?,
91            project: Vec::new(),
92        })
93    }
94
95    /// Load global generators from ~/.ryo/generators/
96    fn load_global() -> Result<Vec<GeneratorEntry>, GeneratorStoreError> {
97        let home = dirs::home_dir().ok_or(GeneratorStoreError::NoHomeDir)?;
98        let global_dir = home.join(".ryo").join(Self::GLOBAL_GENERATORS_DIR);
99
100        if !global_dir.exists() {
101            return Ok(Vec::new());
102        }
103
104        Self::load_from_dir(&global_dir, GeneratorScope::Global)
105    }
106
107    /// Load project-local generators from <project>/.ryo/generators/
108    fn load_project(project_path: &Path) -> Result<Vec<GeneratorEntry>, GeneratorStoreError> {
109        let project_dir = project_path.join(Self::PROJECT_GENERATORS_DIR);
110
111        if !project_dir.exists() {
112            return Ok(Vec::new());
113        }
114
115        Self::load_from_dir(&project_dir, GeneratorScope::Project)
116    }
117
118    /// Load all generator files from a directory
119    fn load_from_dir(
120        dir: &Path,
121        scope: GeneratorScope,
122    ) -> Result<Vec<GeneratorEntry>, GeneratorStoreError> {
123        let templates = GeneratorLoader::load_dir(dir)?;
124        Ok(templates
125            .into_iter()
126            .map(|template| GeneratorEntry { template, scope })
127            .collect())
128    }
129
130    /// Find a generator by ID (project shadows global)
131    pub fn find_by_id(&self, id: &str) -> Option<&GeneratorEntry> {
132        // Project first (higher priority)
133        if let Some(entry) = self.project.iter().find(|e| e.template.id() == id) {
134            return Some(entry);
135        }
136
137        // Then global
138        self.global.iter().find(|e| e.template.id() == id)
139    }
140
141    /// Find a generator by name (project shadows global)
142    pub fn find_by_name(&self, name: &str) -> Option<&GeneratorEntry> {
143        // Project first (higher priority)
144        if let Some(entry) = self.project.iter().find(|e| e.template.name() == name) {
145            return Some(entry);
146        }
147
148        // Then global
149        self.global.iter().find(|e| e.template.name() == name)
150    }
151
152    /// Get all generators (project entries shadow global with same ID)
153    pub fn all_generators(&self) -> impl Iterator<Item = &GeneratorEntry> {
154        // Collect project IDs for shadowing
155        let project_ids: std::collections::HashSet<_> =
156            self.project.iter().map(|e| e.template.id()).collect();
157
158        // Project generators + non-shadowed global generators
159        self.project.iter().chain(
160            self.global
161                .iter()
162                .filter(move |e| !project_ids.contains(e.template.id())),
163        )
164    }
165
166    /// Get number of generators
167    pub fn len(&self) -> usize {
168        // Count unique IDs (project shadows global)
169        let project_ids: std::collections::HashSet<_> =
170            self.project.iter().map(|e| e.template.id()).collect();
171
172        let global_unique = self
173            .global
174            .iter()
175            .filter(|e| !project_ids.contains(e.template.id()))
176            .count();
177
178        self.project.len() + global_unique
179    }
180
181    /// Check if store is empty
182    pub fn is_empty(&self) -> bool {
183        self.global.is_empty() && self.project.is_empty()
184    }
185
186    /// List all generator names with their scope
187    pub fn list_names(&self) -> Vec<(&str, GeneratorScope)> {
188        self.all_generators()
189            .map(|e| (e.template.name(), e.scope))
190            .collect()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use std::io::Write;
198    use tempfile::TempDir;
199
200    fn create_test_generator(dir: &Path, name: &str) {
201        let content = format!(
202            r#"
203generator:
204  id: "{name}"
205  name: "{name}"
206  description: Test generator
207
208params:
209  - name: value
210    description: Test param
211    required: true
212
213template:
214  code: "// Generated: {{{{value}}}}"
215"#
216        );
217
218        let path = dir.join(format!("{}.yaml", name));
219        let mut file = std::fs::File::create(path).unwrap();
220        file.write_all(content.as_bytes()).unwrap();
221    }
222
223    #[test]
224    fn test_load_from_dir() {
225        let temp = TempDir::new().unwrap();
226        create_test_generator(temp.path(), "test_gen");
227
228        let entries = GeneratorStore::load_from_dir(temp.path(), GeneratorScope::Project).unwrap();
229        assert_eq!(entries.len(), 1);
230        assert_eq!(entries[0].template.name(), "test_gen");
231    }
232
233    #[test]
234    fn test_find_by_name() {
235        let temp = TempDir::new().unwrap();
236        let gen_dir = temp.path().join(".ryo/generators");
237        std::fs::create_dir_all(&gen_dir).unwrap();
238        create_test_generator(&gen_dir, "my_generator");
239
240        let store = GeneratorStore::load(temp.path()).unwrap();
241        let entry = store.find_by_name("my_generator");
242        assert!(entry.is_some());
243        assert_eq!(entry.unwrap().scope, GeneratorScope::Project);
244    }
245
246    #[test]
247    fn test_empty_store() {
248        let store = GeneratorStore::new();
249        assert!(store.is_empty());
250        assert_eq!(store.len(), 0);
251    }
252}