Skip to main content

hypen_server/
discovery.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::error::{Result, SdkError};
5
6/// A discovered component definition (template + optional module).
7#[derive(Debug, Clone)]
8pub struct ComponentEntry {
9    /// Component name (derived from filename or explicit).
10    pub name: String,
11    /// Hypen DSL source for the component's UI.
12    pub source: String,
13    /// File path this was loaded from (if file-based).
14    pub path: Option<PathBuf>,
15}
16
17/// Registry for discovered and manually registered components.
18///
19/// Components can be loaded from:
20/// - Individual files
21/// - A components directory (auto-discovery)
22/// - Inline registration
23///
24/// # Example
25///
26/// ```rust,ignore
27/// use hypen_server::discovery::ComponentRegistry;
28///
29/// let mut registry = ComponentRegistry::new();
30///
31/// // Manual registration
32/// registry.register("Button", r#"Button { Text("Click") }"#, None);
33///
34/// // Load from a directory
35/// registry.load_dir("./components")?;
36///
37/// // Look up
38/// let button = registry.get("Button");
39/// ```
40pub struct ComponentRegistry {
41    components: HashMap<String, ComponentEntry>,
42}
43
44impl ComponentRegistry {
45    pub fn new() -> Self {
46        Self {
47            components: HashMap::new(),
48        }
49    }
50
51    /// Register a component with inline Hypen DSL source.
52    pub fn register(
53        &mut self,
54        name: impl Into<String>,
55        source: impl Into<String>,
56        path: Option<PathBuf>,
57    ) {
58        let name = name.into();
59        self.components.insert(
60            name.clone(),
61            ComponentEntry {
62                name,
63                source: source.into(),
64                path,
65            },
66        );
67    }
68
69    /// Load all `.hypen` files from a directory.
70    ///
71    /// Component names are derived from filenames:
72    /// - `button.hypen` -> `"Button"`
73    /// - `user-card.hypen` -> `"UserCard"`
74    /// - `my_component.hypen` -> `"MyComponent"`
75    pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> Result<Vec<String>> {
76        let dir = dir.as_ref();
77        if !dir.is_dir() {
78            return Err(SdkError::Component(format!(
79                "Not a directory: {}",
80                dir.display()
81            )));
82        }
83
84        let mut loaded = Vec::new();
85        let entries = std::fs::read_dir(dir).map_err(|e| {
86            SdkError::Component(format!("Failed to read directory {}: {e}", dir.display()))
87        })?;
88
89        for entry in entries {
90            let entry = entry.map_err(|e| SdkError::Component(e.to_string()))?;
91            let path = entry.path();
92
93            // Folder-based: Name/component.hypen (e.g., Feed/component.hypen → "Feed")
94            if path.is_dir() {
95                let component_file = path.join("component.hypen");
96                if component_file.exists() {
97                    let name = path
98                        .file_name()
99                        .and_then(|s| s.to_str())
100                        .unwrap_or("Unknown")
101                        .to_string();
102                    let source = std::fs::read_to_string(&component_file).map_err(|e| {
103                        SdkError::Component(format!(
104                            "Failed to read {}: {e}",
105                            component_file.display()
106                        ))
107                    })?;
108                    self.register(&name, source, Some(component_file));
109                    loaded.push(name);
110                    continue;
111                }
112                // Also check index.hypen
113                let index_file = path.join("index.hypen");
114                if index_file.exists() {
115                    let name = path
116                        .file_name()
117                        .and_then(|s| s.to_str())
118                        .unwrap_or("Unknown")
119                        .to_string();
120                    let source = std::fs::read_to_string(&index_file).map_err(|e| {
121                        SdkError::Component(format!(
122                            "Failed to read {}: {e}",
123                            index_file.display()
124                        ))
125                    })?;
126                    self.register(&name, source, Some(index_file));
127                    loaded.push(name);
128                    continue;
129                }
130            }
131
132            // Sibling file: name.hypen (e.g., my-button.hypen → "MyButton")
133            if path.extension().and_then(|e| e.to_str()) == Some("hypen") {
134                let stem = path
135                    .file_stem()
136                    .and_then(|s| s.to_str())
137                    .unwrap_or("Unknown");
138                // Skip component.hypen and index.hypen in the root (handled by folder pattern)
139                if stem == "component" || stem == "index" {
140                    continue;
141                }
142                let name = to_pascal_case(stem);
143                let source = std::fs::read_to_string(&path).map_err(|e| {
144                    SdkError::Component(format!("Failed to read {}: {e}", path.display()))
145                })?;
146
147                self.register(&name, source, Some(path));
148                loaded.push(name);
149            }
150        }
151
152        Ok(loaded)
153    }
154
155    /// Load a single component from a file path.
156    pub fn load_file(&mut self, path: impl AsRef<Path>) -> Result<String> {
157        let path = path.as_ref();
158        let stem = path
159            .file_stem()
160            .and_then(|s| s.to_str())
161            .unwrap_or("Unknown");
162        let name = to_pascal_case(stem);
163        let source = std::fs::read_to_string(path)
164            .map_err(|e| SdkError::Component(format!("Failed to read {}: {e}", path.display())))?;
165
166        self.register(&name, source, Some(path.to_path_buf()));
167        Ok(name)
168    }
169
170    /// Get a component by name.
171    pub fn get(&self, name: &str) -> Option<&ComponentEntry> {
172        self.components.get(name)
173    }
174
175    /// Check if a component is registered.
176    pub fn has(&self, name: &str) -> bool {
177        self.components.contains_key(name)
178    }
179
180    /// Get all registered component names.
181    pub fn names(&self) -> Vec<String> {
182        self.components.keys().cloned().collect()
183    }
184
185    /// Get all registered component entries.
186    pub fn all(&self) -> Vec<&ComponentEntry> {
187        self.components.values().collect()
188    }
189
190    /// Remove a component.
191    pub fn remove(&mut self, name: &str) -> Option<ComponentEntry> {
192        self.components.remove(name)
193    }
194
195    /// Clear all registered components.
196    pub fn clear(&mut self) {
197        self.components.clear();
198    }
199
200    /// Number of registered components.
201    pub fn len(&self) -> usize {
202        self.components.len()
203    }
204
205    /// Whether the registry is empty.
206    pub fn is_empty(&self) -> bool {
207        self.components.is_empty()
208    }
209}
210
211impl Default for ComponentRegistry {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217/// Convert a kebab-case or snake_case filename to PascalCase.
218///
219/// - `button` -> `Button`
220/// - `user-card` -> `UserCard`
221/// - `my_component` -> `MyComponent`
222fn to_pascal_case(input: &str) -> String {
223    input
224        .split(['-', '_'])
225        .filter(|s| !s.is_empty())
226        .map(|word| {
227            let mut chars = word.chars();
228            match chars.next() {
229                Some(c) => {
230                    let upper: String = c.to_uppercase().collect();
231                    upper + chars.as_str()
232                }
233                None => String::new(),
234            }
235        })
236        .collect()
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_to_pascal_case() {
245        assert_eq!(to_pascal_case("button"), "Button");
246        assert_eq!(to_pascal_case("user-card"), "UserCard");
247        assert_eq!(to_pascal_case("my_component"), "MyComponent");
248        assert_eq!(to_pascal_case("a-b-c"), "ABC");
249        assert_eq!(to_pascal_case("already"), "Already");
250    }
251
252    #[test]
253    fn test_register_and_get() {
254        let mut registry = ComponentRegistry::new();
255        registry.register("Button", r#"Button { Text("Click") }"#, None);
256
257        assert!(registry.has("Button"));
258        let entry = registry.get("Button").unwrap();
259        assert_eq!(entry.name, "Button");
260        assert!(entry.source.contains("Button"));
261    }
262
263    #[test]
264    fn test_names_and_len() {
265        let mut registry = ComponentRegistry::new();
266        registry.register("A", "A {}", None);
267        registry.register("B", "B {}", None);
268
269        assert_eq!(registry.len(), 2);
270        let mut names = registry.names();
271        names.sort();
272        assert_eq!(names, vec!["A", "B"]);
273    }
274
275    #[test]
276    fn test_remove() {
277        let mut registry = ComponentRegistry::new();
278        registry.register("A", "A {}", None);
279        assert!(registry.has("A"));
280
281        registry.remove("A");
282        assert!(!registry.has("A"));
283    }
284
285    #[test]
286    fn test_load_dir_with_hypen_files() {
287        let dir = std::env::temp_dir().join("hypen_test_load_dir");
288        let _ = std::fs::remove_dir_all(&dir);
289        std::fs::create_dir_all(&dir).unwrap();
290
291        std::fs::write(dir.join("my-button.hypen"), r#"Button { Text("Click") }"#).unwrap();
292        std::fs::write(dir.join("user_card.hypen"), r#"Column { Text("User") }"#).unwrap();
293        // Non-.hypen file should be ignored
294        std::fs::write(dir.join("readme.txt"), "ignore me").unwrap();
295
296        let mut registry = ComponentRegistry::new();
297        let loaded = registry.load_dir(&dir).unwrap();
298
299        assert_eq!(loaded.len(), 2);
300        assert!(registry.has("MyButton"));
301        assert!(registry.has("UserCard"));
302        assert!(!registry.has("Readme"));
303
304        let btn = registry.get("MyButton").unwrap();
305        assert!(btn.source.contains("Button"));
306        assert!(btn.path.is_some());
307
308        let _ = std::fs::remove_dir_all(&dir);
309    }
310
311    #[test]
312    fn test_load_dir_nonexistent() {
313        let mut registry = ComponentRegistry::new();
314        let result = registry.load_dir("/tmp/hypen_nonexistent_dir_xyz");
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn test_load_file() {
320        let dir = std::env::temp_dir().join("hypen_test_load_file");
321        let _ = std::fs::remove_dir_all(&dir);
322        std::fs::create_dir_all(&dir).unwrap();
323
324        let path = dir.join("counter-view.hypen");
325        std::fs::write(&path, r#"Column { Text("Count") }"#).unwrap();
326
327        let mut registry = ComponentRegistry::new();
328        let name = registry.load_file(&path).unwrap();
329
330        assert_eq!(name, "CounterView");
331        assert!(registry.has("CounterView"));
332        assert_eq!(
333            registry.get("CounterView").unwrap().source,
334            r#"Column { Text("Count") }"#
335        );
336
337        let _ = std::fs::remove_dir_all(&dir);
338    }
339
340    #[test]
341    fn test_load_file_nonexistent() {
342        let mut registry = ComponentRegistry::new();
343        let result = registry.load_file("/tmp/hypen_no_such_file.hypen");
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn test_clear() {
349        let mut registry = ComponentRegistry::new();
350        registry.register("A", "A {}", None);
351        registry.register("B", "B {}", None);
352
353        registry.clear();
354        assert!(registry.is_empty());
355    }
356}