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.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 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 pub fn get(&self, name: &str) -> Option<&ComponentEntry> {
128 self.components.get(name)
129 }
130
131 pub fn has(&self, name: &str) -> bool {
133 self.components.contains_key(name)
134 }
135
136 pub fn names(&self) -> Vec<String> {
138 self.components.keys().cloned().collect()
139 }
140
141 pub fn all(&self) -> Vec<&ComponentEntry> {
143 self.components.values().collect()
144 }
145
146 pub fn remove(&mut self, name: &str) -> Option<ComponentEntry> {
148 self.components.remove(name)
149 }
150
151 pub fn clear(&mut self) {
153 self.components.clear();
154 }
155
156 pub fn len(&self) -> usize {
158 self.components.len()
159 }
160
161 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
173fn 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 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}