Skip to main content

wm_memory/
galaxy_registry.rs

1//! Dynamic galaxy management — project-scoped galaxy creation.
2//!
3//! Phase 6.8: Extends the fixed 14-galaxy taxonomy with a dynamic
4//! registry for project-scoped galaxies. Custom galaxies are stored
5//! as LMDB named databases alongside the built-in ones.
6
7use lmdb::{Cursor, Database, DatabaseFlags, Environment, Transaction, WriteFlags};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use wm_core::{CoreError, Galaxy, Result};
11
12/// Metadata for a dynamic (custom) galaxy.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GalaxyMetadata {
15    /// Unique galaxy name (used as LMDB sub-database name)
16    pub name: String,
17    /// Human-readable description
18    pub description: String,
19    /// Project scope (empty = global)
20    pub project: String,
21    /// Creation timestamp
22    pub created_at: i64,
23    /// Whether this galaxy is read-only
24    pub read_only: bool,
25}
26
27/// Registry for dynamic galaxies — allows creating custom galaxies
28/// beyond the fixed 14 built-in ones.
29///
30/// Custom galaxy metadata is stored in a dedicated LMDB sub-database
31/// called `_galaxy_registry`. Each custom galaxy gets its own LMDB
32/// sub-database for storing memories.
33pub struct GalaxyRegistry {
34    /// Registry sub-database (stores GalaxyMetadata entries)
35    registry_db: Database,
36    /// Cache of opened custom galaxy databases
37    db_cache: HashMap<String, Database>,
38}
39
40impl GalaxyRegistry {
41    /// Open or create a galaxy registry in the given LMDB environment.
42    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    /// Register a new custom galaxy.
54    ///
55    /// Creates the LMDB sub-database and stores metadata.
56    /// Returns error if the name conflicts with a built-in galaxy
57    /// or an existing custom galaxy.
58    pub fn create(
59        &mut self,
60        env: &Environment,
61        name: &str,
62        description: &str,
63        project: &str,
64    ) -> Result<GalaxyMetadata> {
65        // Check for conflict with built-in galaxies
66        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        // Check for existing custom galaxy
73        if self.get(env, name)?.is_some() {
74            return Err(CoreError::Memory(format!("galaxy '{name}' already exists")));
75        }
76
77        // Create the LMDB sub-database
78        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        // Store metadata
83        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    /// Get metadata for a custom galaxy by name.
106    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    /// List all registered custom galaxies.
127    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    /// Delete a custom galaxy's metadata from the registry.
151    ///
152    /// Cannot delete built-in galaxies. The LMDB sub-database itself
153    /// remains (dropping databases requires unsafe LMDB API), but the
154    /// metadata is removed so the galaxy is no longer listed.
155    pub fn delete(&mut self, env: &Environment, name: &str) -> Result<()> {
156        // Check for conflict with built-in galaxies
157        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        // Check it exists
164        if self.get(env, name)?.is_none() {
165            return Err(CoreError::Memory(format!("galaxy '{name}' does not exist")));
166        }
167
168        // Delete metadata from registry
169        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        // Remove from cache
178        self.db_cache.remove(name);
179        Ok(())
180    }
181
182    /// Open a custom galaxy's LMDB database handle.
183    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    /// Check if a galaxy name (built-in or custom) exists.
196    pub fn exists(&self, env: &Environment, name: &str) -> bool {
197        // Check built-in
198        if Galaxy::all().iter().any(|g| g.db_name() == name) {
199            return true;
200        }
201        // Check custom
202        self.get(env, name).is_ok_and(|opt| opt.is_some())
203    }
204
205    /// Count of custom (non-built-in) galaxies.
206    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}