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!("Failed to read {}: {e}", index_file.display()))
122                    })?;
123                    self.register(&name, source, Some(index_file));
124                    loaded.push(name);
125                    continue;
126                }
127            }
128
129            // Sibling file: name.hypen (e.g., my-button.hypen → "MyButton")
130            if path.extension().and_then(|e| e.to_str()) == Some("hypen") {
131                let stem = path
132                    .file_stem()
133                    .and_then(|s| s.to_str())
134                    .unwrap_or("Unknown");
135                // Skip component.hypen and index.hypen in the root (handled by folder pattern)
136                if stem == "component" || stem == "index" {
137                    continue;
138                }
139                let name = to_pascal_case(stem);
140                let source = std::fs::read_to_string(&path).map_err(|e| {
141                    SdkError::Component(format!("Failed to read {}: {e}", path.display()))
142                })?;
143
144                self.register(&name, source, Some(path));
145                loaded.push(name);
146            }
147        }
148
149        Ok(loaded)
150    }
151
152    /// Load a single component from a file path.
153    pub fn load_file(&mut self, path: impl AsRef<Path>) -> Result<String> {
154        let path = path.as_ref();
155        let stem = path
156            .file_stem()
157            .and_then(|s| s.to_str())
158            .unwrap_or("Unknown");
159        let name = to_pascal_case(stem);
160        let source = std::fs::read_to_string(path)
161            .map_err(|e| SdkError::Component(format!("Failed to read {}: {e}", path.display())))?;
162
163        self.register(&name, source, Some(path.to_path_buf()));
164        Ok(name)
165    }
166
167    /// Get a component by name.
168    pub fn get(&self, name: &str) -> Option<&ComponentEntry> {
169        self.components.get(name)
170    }
171
172    /// Check if a component is registered.
173    pub fn has(&self, name: &str) -> bool {
174        self.components.contains_key(name)
175    }
176
177    /// Get all registered component names.
178    pub fn names(&self) -> Vec<String> {
179        self.components.keys().cloned().collect()
180    }
181
182    /// Get all registered component entries.
183    pub fn all(&self) -> Vec<&ComponentEntry> {
184        self.components.values().collect()
185    }
186
187    /// Remove a component.
188    pub fn remove(&mut self, name: &str) -> Option<ComponentEntry> {
189        self.components.remove(name)
190    }
191
192    /// Clear all registered components.
193    pub fn clear(&mut self) {
194        self.components.clear();
195    }
196
197    /// Number of registered components.
198    pub fn len(&self) -> usize {
199        self.components.len()
200    }
201
202    /// Whether the registry is empty.
203    pub fn is_empty(&self) -> bool {
204        self.components.is_empty()
205    }
206}
207
208impl Default for ComponentRegistry {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214/// Convert a kebab-case or snake_case filename to PascalCase.
215///
216/// - `button` -> `Button`
217/// - `user-card` -> `UserCard`
218/// - `my_component` -> `MyComponent`
219fn to_pascal_case(input: &str) -> String {
220    input
221        .split(['-', '_'])
222        .filter(|s| !s.is_empty())
223        .map(|word| {
224            let mut chars = word.chars();
225            match chars.next() {
226                Some(c) => {
227                    let upper: String = c.to_uppercase().collect();
228                    upper + chars.as_str()
229                }
230                None => String::new(),
231            }
232        })
233        .collect()
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_to_pascal_case() {
242        assert_eq!(to_pascal_case("button"), "Button");
243        assert_eq!(to_pascal_case("user-card"), "UserCard");
244        assert_eq!(to_pascal_case("my_component"), "MyComponent");
245        assert_eq!(to_pascal_case("a-b-c"), "ABC");
246        assert_eq!(to_pascal_case("already"), "Already");
247    }
248
249    #[test]
250    fn test_register_and_get() {
251        let mut registry = ComponentRegistry::new();
252        registry.register("Button", r#"Button { Text("Click") }"#, None);
253
254        assert!(registry.has("Button"));
255        let entry = registry.get("Button").unwrap();
256        assert_eq!(entry.name, "Button");
257        assert!(entry.source.contains("Button"));
258    }
259
260    #[test]
261    fn test_names_and_len() {
262        let mut registry = ComponentRegistry::new();
263        registry.register("A", "A {}", None);
264        registry.register("B", "B {}", None);
265
266        assert_eq!(registry.len(), 2);
267        let mut names = registry.names();
268        names.sort();
269        assert_eq!(names, vec!["A", "B"]);
270    }
271
272    #[test]
273    fn test_remove() {
274        let mut registry = ComponentRegistry::new();
275        registry.register("A", "A {}", None);
276        assert!(registry.has("A"));
277
278        registry.remove("A");
279        assert!(!registry.has("A"));
280    }
281
282    #[test]
283    fn test_load_dir_with_hypen_files() {
284        let dir = std::env::temp_dir().join("hypen_test_load_dir");
285        let _ = std::fs::remove_dir_all(&dir);
286        std::fs::create_dir_all(&dir).unwrap();
287
288        std::fs::write(dir.join("my-button.hypen"), r#"Button { Text("Click") }"#).unwrap();
289        std::fs::write(dir.join("user_card.hypen"), r#"Column { Text("User") }"#).unwrap();
290        // Non-.hypen file should be ignored
291        std::fs::write(dir.join("readme.txt"), "ignore me").unwrap();
292
293        let mut registry = ComponentRegistry::new();
294        let loaded = registry.load_dir(&dir).unwrap();
295
296        assert_eq!(loaded.len(), 2);
297        assert!(registry.has("MyButton"));
298        assert!(registry.has("UserCard"));
299        assert!(!registry.has("Readme"));
300
301        let btn = registry.get("MyButton").unwrap();
302        assert!(btn.source.contains("Button"));
303        assert!(btn.path.is_some());
304
305        let _ = std::fs::remove_dir_all(&dir);
306    }
307
308    #[test]
309    fn test_load_dir_nonexistent() {
310        let mut registry = ComponentRegistry::new();
311        let result = registry.load_dir("/tmp/hypen_nonexistent_dir_xyz");
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn test_load_file() {
317        let dir = std::env::temp_dir().join("hypen_test_load_file");
318        let _ = std::fs::remove_dir_all(&dir);
319        std::fs::create_dir_all(&dir).unwrap();
320
321        let path = dir.join("counter-view.hypen");
322        std::fs::write(&path, r#"Column { Text("Count") }"#).unwrap();
323
324        let mut registry = ComponentRegistry::new();
325        let name = registry.load_file(&path).unwrap();
326
327        assert_eq!(name, "CounterView");
328        assert!(registry.has("CounterView"));
329        assert_eq!(
330            registry.get("CounterView").unwrap().source,
331            r#"Column { Text("Count") }"#
332        );
333
334        let _ = std::fs::remove_dir_all(&dir);
335    }
336
337    #[test]
338    fn test_load_file_nonexistent() {
339        let mut registry = ComponentRegistry::new();
340        let result = registry.load_file("/tmp/hypen_no_such_file.hypen");
341        assert!(result.is_err());
342    }
343
344    #[test]
345    fn test_clear() {
346        let mut registry = ComponentRegistry::new();
347        registry.register("A", "A {}", None);
348        registry.register("B", "B {}", None);
349
350        registry.clear();
351        assert!(registry.is_empty());
352    }
353}