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