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!("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 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 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 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 pub fn get(&self, name: &str) -> Option<&ComponentEntry> {
169 self.components.get(name)
170 }
171
172 pub fn has(&self, name: &str) -> bool {
174 self.components.contains_key(name)
175 }
176
177 pub fn names(&self) -> Vec<String> {
179 self.components.keys().cloned().collect()
180 }
181
182 pub fn all(&self) -> Vec<&ComponentEntry> {
184 self.components.values().collect()
185 }
186
187 pub fn remove(&mut self, name: &str) -> Option<ComponentEntry> {
189 self.components.remove(name)
190 }
191
192 pub fn clear(&mut self) {
194 self.components.clear();
195 }
196
197 pub fn len(&self) -> usize {
199 self.components.len()
200 }
201
202 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
214fn 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 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}