1use lmdb::{Cursor, Database, DatabaseFlags, Environment, Transaction, WriteFlags};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use wm_core::{CoreError, Galaxy, Result};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GalaxyMetadata {
15 pub name: String,
17 pub description: String,
19 pub project: String,
21 pub created_at: i64,
23 pub read_only: bool,
25}
26
27pub struct GalaxyRegistry {
34 registry_db: Database,
36 db_cache: HashMap<String, Database>,
38}
39
40impl GalaxyRegistry {
41 pub fn open(env: &Environment) -> Result<Self> {
43 let registry_db = env
44 .create_db(Some("_galaxy_registry"), DatabaseFlags::default())
45 .map_err(|e| CoreError::Memory(format!("LMDB create_db for galaxy registry: {e}")))?;
46
47 Ok(Self {
48 registry_db,
49 db_cache: HashMap::new(),
50 })
51 }
52
53 pub fn create(
59 &mut self,
60 env: &Environment,
61 name: &str,
62 description: &str,
63 project: &str,
64 ) -> Result<GalaxyMetadata> {
65 if Galaxy::all().iter().any(|g| g.db_name() == name) {
67 return Err(CoreError::Memory(format!(
68 "galaxy name '{name}' conflicts with built-in galaxy"
69 )));
70 }
71
72 if self.get(env, name)?.is_some() {
74 return Err(CoreError::Memory(format!("galaxy '{name}' already exists")));
75 }
76
77 let _db = env
79 .create_db(Some(name), DatabaseFlags::default())
80 .map_err(|e| CoreError::Memory(format!("LMDB create_db for '{name}': {e}")))?;
81
82 let metadata = GalaxyMetadata {
84 name: name.to_string(),
85 description: description.to_string(),
86 project: project.to_string(),
87 created_at: chrono::Utc::now().timestamp(),
88 read_only: false,
89 };
90
91 let val = rmp_serde::to_vec(&metadata)
92 .map_err(|e| CoreError::Memory(format!("serialize galaxy metadata: {e}")))?;
93
94 let mut tx = env
95 .begin_rw_txn()
96 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn: {e}")))?;
97 tx.put(self.registry_db, &name, &val, WriteFlags::default())
98 .map_err(|e| CoreError::Memory(format!("LMDB put galaxy metadata: {e}")))?;
99 tx.commit()
100 .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
101
102 Ok(metadata)
103 }
104
105 pub fn get(&self, env: &Environment, name: &str) -> Result<Option<GalaxyMetadata>> {
107 let tx = env
108 .begin_ro_txn()
109 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
110 match tx.get(self.registry_db, &name) {
111 Ok(bytes) => {
112 let metadata: GalaxyMetadata = rmp_serde::from_slice(bytes)
113 .map_err(|e| CoreError::Memory(format!("deserialize galaxy metadata: {e}")))?;
114 tx.commit()
115 .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
116 Ok(Some(metadata))
117 }
118 Err(lmdb::Error::NotFound) => {
119 let _ = tx.commit();
120 Ok(None)
121 }
122 Err(e) => Err(CoreError::Memory(format!("LMDB get galaxy metadata: {e}"))),
123 }
124 }
125
126 pub fn list(&self, env: &Environment) -> Result<Vec<GalaxyMetadata>> {
128 let tx = env
129 .begin_ro_txn()
130 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
131
132 let mut cursor = tx
133 .open_ro_cursor(self.registry_db)
134 .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
135
136 let mut galaxies = Vec::new();
137 for (key, val) in cursor.iter() {
138 if let Ok(metadata) = rmp_serde::from_slice::<GalaxyMetadata>(val) {
139 galaxies.push(metadata);
140 }
141 let _ = key;
142 }
143
144 drop(cursor);
145 tx.commit()
146 .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
147 Ok(galaxies)
148 }
149
150 pub fn delete(&mut self, env: &Environment, name: &str) -> Result<()> {
156 if Galaxy::all().iter().any(|g| g.db_name() == name) {
158 return Err(CoreError::Memory(format!(
159 "cannot delete built-in galaxy '{name}'"
160 )));
161 }
162
163 if self.get(env, name)?.is_none() {
165 return Err(CoreError::Memory(format!("galaxy '{name}' does not exist")));
166 }
167
168 let mut tx = env
170 .begin_rw_txn()
171 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn: {e}")))?;
172 tx.del(self.registry_db, &name, None)
173 .map_err(|e| CoreError::Memory(format!("LMDB del galaxy metadata: {e}")))?;
174 tx.commit()
175 .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
176
177 self.db_cache.remove(name);
179 Ok(())
180 }
181
182 pub fn galaxy_db(&mut self, env: &Environment, name: &str) -> Result<Database> {
184 if let Some(db) = self.db_cache.get(name) {
185 return Ok(*db);
186 }
187
188 let db = env
189 .open_db(Some(name))
190 .map_err(|e| CoreError::Memory(format!("LMDB open_db for galaxy '{name}': {e}")))?;
191 self.db_cache.insert(name.to_string(), db);
192 Ok(db)
193 }
194
195 pub fn exists(&self, env: &Environment, name: &str) -> bool {
197 if Galaxy::all().iter().any(|g| g.db_name() == name) {
199 return true;
200 }
201 self.get(env, name).is_ok_and(|opt| opt.is_some())
203 }
204
205 pub fn count(&self, env: &Environment) -> usize {
207 self.list(env).map_or(0, |v| v.len())
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use tempfile::tempdir;
215
216 fn test_registry() -> (tempfile::TempDir, Environment, GalaxyRegistry) {
217 let tmp = tempdir().unwrap();
218 let env = Environment::new()
219 .set_map_size(1024 * 1024)
220 .set_max_dbs(32)
221 .open(tmp.path())
222 .unwrap();
223 let registry = GalaxyRegistry::open(&env).unwrap();
224 (tmp, env, registry)
225 }
226
227 #[test]
228 fn create_custom_galaxy() {
229 let (_tmp, env, mut registry) = test_registry();
230 let meta = registry
231 .create(&env, "project_alpha", "Alpha project memories", "alpha")
232 .unwrap();
233 assert_eq!(meta.name, "project_alpha");
234 assert_eq!(meta.description, "Alpha project memories");
235 assert_eq!(meta.project, "alpha");
236 }
237
238 #[test]
239 fn get_custom_galaxy() {
240 let (_tmp, env, mut registry) = test_registry();
241 registry
242 .create(&env, "project_beta", "Beta project", "beta")
243 .unwrap();
244
245 let meta = registry.get(&env, "project_beta").unwrap();
246 assert!(meta.is_some());
247 assert_eq!(meta.unwrap().name, "project_beta");
248 }
249
250 #[test]
251 fn get_nonexistent_returns_none() {
252 let (_tmp, env, registry) = test_registry();
253 assert!(registry.get(&env, "nonexistent").unwrap().is_none());
254 }
255
256 #[test]
257 fn list_custom_galaxies() {
258 let (_tmp, env, mut registry) = test_registry();
259 registry.create(&env, "galaxy_a", "A", "proj").unwrap();
260 registry.create(&env, "galaxy_b", "B", "proj").unwrap();
261
262 let list = registry.list(&env).unwrap();
263 assert_eq!(list.len(), 2);
264 }
265
266 #[test]
267 fn reject_builtin_galaxy_name() {
268 let (_tmp, env, mut registry) = test_registry();
269 let result = registry.create(&env, "codex", "duplicate", "");
270 assert!(result.is_err(), "should reject built-in galaxy name");
271 }
272
273 #[test]
274 fn reject_duplicate_custom_galaxy() {
275 let (_tmp, env, mut registry) = test_registry();
276 registry.create(&env, "custom", "first", "").unwrap();
277 let result = registry.create(&env, "custom", "second", "");
278 assert!(result.is_err(), "should reject duplicate name");
279 }
280
281 #[test]
282 fn delete_custom_galaxy() {
283 let (_tmp, env, mut registry) = test_registry();
284 registry.create(&env, "to_delete", "temporary", "").unwrap();
285 assert!(registry.get(&env, "to_delete").unwrap().is_some());
286
287 registry.delete(&env, "to_delete").unwrap();
288 assert!(registry.get(&env, "to_delete").unwrap().is_none());
289 }
290
291 #[test]
292 fn cannot_delete_builtin_galaxy() {
293 let (_tmp, env, mut registry) = test_registry();
294 let result = registry.delete(&env, "codex");
295 assert!(result.is_err(), "should not delete built-in galaxy");
296 }
297
298 #[test]
299 fn exists_checks_builtin_and_custom() {
300 let (_tmp, env, mut registry) = test_registry();
301 assert!(
302 registry.exists(&env, "codex"),
303 "should find built-in galaxy"
304 );
305 assert!(
306 !registry.exists(&env, "custom"),
307 "should not find nonexistent"
308 );
309
310 registry.create(&env, "custom", "test", "").unwrap();
311 assert!(registry.exists(&env, "custom"), "should find custom galaxy");
312 }
313
314 #[test]
315 fn galaxy_db_opens_handle() {
316 let (_tmp, env, mut registry) = test_registry();
317 registry.create(&env, "test_db", "test", "").unwrap();
318 let db = registry.galaxy_db(&env, "test_db").unwrap();
319 let _ = db;
320 }
321
322 #[test]
323 fn count_custom_galaxies() {
324 let (_tmp, env, mut registry) = test_registry();
325 assert_eq!(registry.count(&env), 0);
326
327 registry.create(&env, "g1", "", "").unwrap();
328 registry.create(&env, "g2", "", "").unwrap();
329 assert_eq!(registry.count(&env), 2);
330 }
331
332 #[test]
333 fn galaxy_metadata_serializes() {
334 let meta = GalaxyMetadata {
335 name: "test".into(),
336 description: "test galaxy".into(),
337 project: "proj".into(),
338 created_at: 1700000000,
339 read_only: false,
340 };
341 let bytes = rmp_serde::to_vec(&meta).unwrap();
342 let decoded: GalaxyMetadata = rmp_serde::from_slice(&bytes).unwrap();
343 assert_eq!(decoded.name, "test");
344 assert_eq!(decoded.project, "proj");
345 }
346}