hypen_server/
discovery.rs1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::error::{Result, SdkError};
5
6#[derive(Debug, Clone)]
8pub struct ComponentEntry {
9 pub name: String,
11 pub source: String,
13 pub path: Option<PathBuf>,
15}
16
17pub 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 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 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.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 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 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 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 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 pub fn get(&self, name: &str) -> Option<&ComponentEntry> {
172 self.components.get(name)
173 }
174
175 pub fn has(&self, name: &str) -> bool {
177 self.components.contains_key(name)
178 }
179
180 pub fn names(&self) -> Vec<String> {
182 self.components.keys().cloned().collect()
183 }
184
185 pub fn all(&self) -> Vec<&ComponentEntry> {
187 self.components.values().collect()
188 }
189
190 pub fn remove(&mut self, name: &str) -> Option<ComponentEntry> {
192 self.components.remove(name)
193 }
194
195 pub fn clear(&mut self) {
197 self.components.clear();
198 }
199
200 pub fn len(&self) -> usize {
202 self.components.len()
203 }
204
205 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
217fn 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 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}