1use std::path::{Path, PathBuf};
11use std::sync::RwLock;
12
13use chrono::{DateTime, Utc};
14use dashmap::DashMap;
15use serde::{Deserialize, Serialize};
16use tracing::{debug, info, warn};
17
18use crate::hnsw_service::HnswServiceConfig;
19use crate::vector_backend::{SearchResult, VectorBackend, VectorError, VectorResult};
20use crate::vector_hnsw::HnswBackend;
21
22#[cfg(feature = "exochain")]
23use crate::chain::ChainManager;
24#[cfg(feature = "exochain")]
25use crate::governance::{EffectVector, GovernanceDecision, GovernanceEngine, GovernanceRequest};
26#[cfg(feature = "exochain")]
27use std::sync::Arc;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ProfileMeta {
34 pub id: String,
36 pub name: String,
38 pub created_at: DateTime<Utc>,
40 pub vector_count: usize,
42}
43
44pub struct ProfileEntry {
46 pub meta: ProfileMeta,
47 pub backend: Box<dyn VectorBackend>,
48}
49
50impl std::fmt::Debug for ProfileEntry {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("ProfileEntry")
53 .field("meta", &self.meta)
54 .field("backend", &self.meta.id)
55 .finish()
56 }
57}
58
59#[derive(Debug, thiserror::Error)]
63pub enum ProfileError {
64 #[error("profile already exists: '{0}'")]
66 AlreadyExists(String),
67
68 #[error("profile not found: '{0}'")]
70 NotFound(String),
71
72 #[error("invalid profile id: '{0}' -- must be [a-zA-Z0-9_-]+")]
74 InvalidId(String),
75
76 #[error("profile io error: {0}")]
78 Io(#[from] std::io::Error),
79
80 #[error("profile json error: {0}")]
82 Json(#[from] serde_json::Error),
83
84 #[error("governance denied: {0}")]
86 GovernanceDenied(String),
87}
88
89pub type ProfileResult<T> = Result<T, ProfileError>;
90
91pub struct ProfileStore {
98 storage_path: PathBuf,
100 profiles: DashMap<String, ProfileEntry>,
102 active_profile: RwLock<String>,
104 hnsw_config: HnswServiceConfig,
106 #[cfg(feature = "exochain")]
108 chain_manager: Option<Arc<ChainManager>>,
109 #[cfg(feature = "exochain")]
111 governance_engine: Option<Arc<GovernanceEngine>>,
112}
113
114impl ProfileStore {
115 pub fn new(storage_path: PathBuf, hnsw_config: HnswServiceConfig) -> Self {
119 Self {
120 storage_path,
121 profiles: DashMap::new(),
122 active_profile: RwLock::new("default".to_owned()),
123 hnsw_config,
124 #[cfg(feature = "exochain")]
125 chain_manager: None,
126 #[cfg(feature = "exochain")]
127 governance_engine: None,
128 }
129 }
130
131 #[cfg(feature = "exochain")]
133 pub fn set_chain_manager(&mut self, cm: Arc<ChainManager>) {
134 self.chain_manager = Some(cm);
135 }
136
137 #[cfg(feature = "exochain")]
139 pub fn set_governance_engine(&mut self, engine: Arc<GovernanceEngine>) {
140 self.governance_engine = Some(engine);
141 }
142
143 pub fn load_existing(&self) -> ProfileResult<usize> {
147 let dir = &self.storage_path;
148 if !dir.exists() {
149 std::fs::create_dir_all(dir)?;
150 }
151
152 let mut count = 0;
153 for entry in std::fs::read_dir(dir)? {
154 let entry = entry?;
155 if !entry.file_type()?.is_dir() {
156 continue;
157 }
158 let profile_dir = entry.path();
159 let meta_path = profile_dir.join("profile.json");
160 if !meta_path.exists() {
161 continue;
162 }
163 match Self::load_profile_meta(&meta_path) {
164 Ok(meta) => {
165 let backend = Box::new(HnswBackend::new(self.hnsw_config.clone()));
166 debug!(profile_id = %meta.id, "loaded profile from disk");
167 self.profiles
168 .insert(meta.id.clone(), ProfileEntry { meta, backend });
169 count += 1;
170 }
171 Err(e) => {
172 warn!(path = %meta_path.display(), error = %e, "failed to load profile");
173 }
174 }
175 }
176
177 if self.profiles.is_empty() {
179 info!("no profiles found, creating default profile");
180 self.create_profile_inner("default", "Default Profile")?;
181 count = 1;
182 }
183
184 Ok(count)
185 }
186
187 pub fn create_profile(&self, id: &str, name: &str) -> ProfileResult<ProfileMeta> {
189 Self::validate_id(id)?;
190
191 if self.profiles.contains_key(id) {
192 return Err(ProfileError::AlreadyExists(id.to_owned()));
193 }
194
195 #[cfg(feature = "exochain")]
197 if let Some(ref engine) = self.governance_engine {
198 let req =
199 GovernanceRequest::new("system", "profile.create").with_effect(EffectVector {
200 risk: 0.3,
201 privacy: 0.2,
202 ..Default::default()
203 });
204 let result = engine.evaluate(&req);
205 match &result.decision {
206 GovernanceDecision::Deny(reason) | GovernanceDecision::EscalateToHuman(reason) => {
207 return Err(ProfileError::GovernanceDenied(reason.clone()));
208 }
209 _ => {}
210 }
211 }
212
213 let meta = self.create_profile_inner(id, name)?;
214
215 #[cfg(feature = "exochain")]
217 if let Some(ref cm) = self.chain_manager {
218 cm.append(
219 "profile_store",
220 crate::chain::EVENT_KIND_PROFILE_CREATE,
221 Some(serde_json::json!({
222 "profile_id": id,
223 "name": name,
224 })),
225 );
226 }
227
228 Ok(meta)
229 }
230
231 pub fn delete_profile(&self, id: &str) -> ProfileResult<()> {
233 if !self.profiles.contains_key(id) {
234 return Err(ProfileError::NotFound(id.to_owned()));
235 }
236
237 {
239 let active = self
240 .active_profile
241 .read()
242 .expect("active_profile lock poisoned");
243 if *active == id {
244 return Err(ProfileError::InvalidId(format!(
245 "cannot delete active profile '{id}'; switch first"
246 )));
247 }
248 }
249
250 #[cfg(feature = "exochain")]
252 if let Some(ref engine) = self.governance_engine {
253 let req =
254 GovernanceRequest::new("system", "profile.delete").with_effect(EffectVector {
255 risk: 0.7,
256 privacy: 0.4,
257 ..Default::default()
258 });
259 let result = engine.evaluate(&req);
260 match &result.decision {
261 GovernanceDecision::Deny(reason) | GovernanceDecision::EscalateToHuman(reason) => {
262 return Err(ProfileError::GovernanceDenied(reason.clone()));
263 }
264 _ => {}
265 }
266 }
267
268 self.profiles.remove(id);
269
270 let dir = self.storage_path.join(id);
272 if dir.exists() {
273 std::fs::remove_dir_all(&dir)?;
274 }
275
276 #[cfg(feature = "exochain")]
278 if let Some(ref cm) = self.chain_manager {
279 cm.append(
280 "profile_store",
281 crate::chain::EVENT_KIND_PROFILE_DELETE,
282 Some(serde_json::json!({
283 "profile_id": id,
284 })),
285 );
286 }
287
288 info!(profile_id = %id, "deleted profile");
289 Ok(())
290 }
291
292 pub fn list_profiles(&self) -> Vec<ProfileMeta> {
294 self.profiles
295 .iter()
296 .map(|entry| {
297 let mut meta = entry.value().meta.clone();
298 meta.vector_count = entry.value().backend.len();
299 meta
300 })
301 .collect()
302 }
303
304 pub fn switch_profile(&self, id: &str) -> ProfileResult<()> {
307 if !self.profiles.contains_key(id) {
308 return Err(ProfileError::NotFound(id.to_owned()));
309 }
310
311 let previous = self.active_profile_id();
312 let mut active = self
313 .active_profile
314 .write()
315 .expect("active_profile lock poisoned");
316 *active = id.to_owned();
317
318 #[cfg(feature = "exochain")]
320 if let Some(ref cm) = self.chain_manager {
321 cm.append(
322 "profile_store",
323 crate::chain::EVENT_KIND_PROFILE_SWITCH,
324 Some(serde_json::json!({
325 "profile_id": id,
326 "previous": previous,
327 })),
328 );
329 }
330
331 info!(profile_id = %id, "switched active profile");
332 Ok(())
333 }
334
335 pub fn active_profile_id(&self) -> String {
337 self.active_profile
338 .read()
339 .expect("active_profile lock poisoned")
340 .clone()
341 }
342
343 pub fn get_profile_backend(
347 &self,
348 id: &str,
349 ) -> Option<dashmap::mapref::one::Ref<'_, String, ProfileEntry>> {
350 self.profiles.get(id)
351 }
352
353 pub fn active_backend(&self) -> Option<dashmap::mapref::one::Ref<'_, String, ProfileEntry>> {
355 let id = self.active_profile_id();
356 self.profiles.get(&id)
357 }
358
359 pub fn insert(
361 &self,
362 id: u64,
363 key: &str,
364 vector: &[f32],
365 metadata: serde_json::Value,
366 ) -> VectorResult<()> {
367 let profile_id = self.active_profile_id();
368 let entry = self.profiles.get(&profile_id).ok_or_else(|| {
369 VectorError::Other(format!("active profile '{profile_id}' not found"))
370 })?;
371 let result = entry.backend.insert(id, key, vector, metadata);
372
373 #[cfg(feature = "exochain")]
375 if result.is_ok()
376 && let Some(ref cm) = self.chain_manager
377 {
378 cm.append(
379 "profile_store",
380 crate::chain::EVENT_KIND_PROFILE_VECTOR_INSERT,
381 Some(serde_json::json!({
382 "profile_id": profile_id,
383 "vector_id": id,
384 "key": key,
385 })),
386 );
387 }
388
389 result
390 }
391
392 pub fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
394 let profile_id = self.active_profile_id();
395 match self.profiles.get(&profile_id) {
396 Some(entry) => entry.backend.search(query, k),
397 None => Vec::new(),
398 }
399 }
400
401 pub fn len(&self) -> usize {
403 self.profiles.len()
404 }
405
406 pub fn is_empty(&self) -> bool {
408 self.profiles.is_empty()
409 }
410
411 pub fn persist_meta(&self, id: &str) -> ProfileResult<()> {
413 let entry = self
414 .profiles
415 .get(id)
416 .ok_or_else(|| ProfileError::NotFound(id.to_owned()))?;
417
418 let mut meta = entry.meta.clone();
419 meta.vector_count = entry.backend.len();
420
421 let dir = self.storage_path.join(id);
422 std::fs::create_dir_all(&dir)?;
423 let meta_path = dir.join("profile.json");
424 let json = serde_json::to_string_pretty(&meta)?;
425 std::fs::write(&meta_path, json)?;
426 Ok(())
427 }
428
429 fn create_profile_inner(&self, id: &str, name: &str) -> ProfileResult<ProfileMeta> {
432 let meta = ProfileMeta {
433 id: id.to_owned(),
434 name: name.to_owned(),
435 created_at: Utc::now(),
436 vector_count: 0,
437 };
438
439 let backend = Box::new(HnswBackend::new(self.hnsw_config.clone()));
440
441 let dir = self.storage_path.join(id);
443 std::fs::create_dir_all(dir.join("vectors"))?;
444 let meta_path = dir.join("profile.json");
445 let json = serde_json::to_string_pretty(&meta)?;
446 std::fs::write(&meta_path, json)?;
447
448 self.profiles.insert(
449 id.to_owned(),
450 ProfileEntry {
451 meta: meta.clone(),
452 backend,
453 },
454 );
455
456 info!(profile_id = %id, name = %name, "created profile");
457 Ok(meta)
458 }
459
460 fn load_profile_meta(path: &Path) -> ProfileResult<ProfileMeta> {
461 let data = std::fs::read_to_string(path)?;
462 let meta: ProfileMeta = serde_json::from_str(&data)?;
463 Ok(meta)
464 }
465
466 fn validate_id(id: &str) -> ProfileResult<()> {
467 if id.is_empty() {
468 return Err(ProfileError::InvalidId("empty id".to_owned()));
469 }
470 if !id
471 .chars()
472 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
473 {
474 return Err(ProfileError::InvalidId(id.to_owned()));
475 }
476 Ok(())
477 }
478}
479
480impl std::fmt::Debug for ProfileStore {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("ProfileStore")
483 .field("storage_path", &self.storage_path)
484 .field("profiles", &self.profiles.len())
485 .field("active", &self.active_profile_id())
486 .finish()
487 }
488}
489
490#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn tmp_dir(name: &str) -> PathBuf {
497 std::env::temp_dir().join(format!(
498 "profile_store_test_{name}_{}",
499 std::time::SystemTime::now()
500 .duration_since(std::time::UNIX_EPOCH)
501 .unwrap()
502 .as_nanos()
503 ))
504 }
505
506 fn make_store(name: &str) -> (ProfileStore, PathBuf) {
507 let dir = tmp_dir(name);
508 let store = ProfileStore::new(dir.clone(), HnswServiceConfig::default());
509 (store, dir)
510 }
511
512 fn cleanup(dir: &Path) {
513 let _ = std::fs::remove_dir_all(dir);
514 }
515
516 #[test]
517 fn create_and_list_profiles() {
518 let (store, dir) = make_store("create_list");
519 store.create_profile("alice", "Alice").unwrap();
520 store.create_profile("bob", "Bob").unwrap();
521
522 let profiles = store.list_profiles();
523 assert_eq!(profiles.len(), 2);
524
525 let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
526 assert!(names.contains(&"Alice"));
527 assert!(names.contains(&"Bob"));
528
529 cleanup(&dir);
530 }
531
532 #[test]
533 fn duplicate_profile_rejected() {
534 let (store, dir) = make_store("duplicate");
535 store.create_profile("alice", "Alice").unwrap();
536 assert!(matches!(
537 store.create_profile("alice", "Alice Again"),
538 Err(ProfileError::AlreadyExists(_))
539 ));
540 cleanup(&dir);
541 }
542
543 #[test]
544 fn invalid_id_rejected() {
545 let (store, dir) = make_store("invalid_id");
546 assert!(matches!(
547 store.create_profile("a b c", "Bad"),
548 Err(ProfileError::InvalidId(_))
549 ));
550 assert!(matches!(
551 store.create_profile("", "Empty"),
552 Err(ProfileError::InvalidId(_))
553 ));
554 assert!(matches!(
555 store.create_profile("a/b", "Slash"),
556 Err(ProfileError::InvalidId(_))
557 ));
558 cleanup(&dir);
559 }
560
561 #[test]
562 fn switch_and_active_profile() {
563 let (store, dir) = make_store("switch");
564 store.create_profile("alice", "Alice").unwrap();
565 store.create_profile("bob", "Bob").unwrap();
566
567 assert_eq!(store.active_profile_id(), "default");
568
569 store.switch_profile("alice").unwrap();
570 assert_eq!(store.active_profile_id(), "alice");
571
572 store.switch_profile("bob").unwrap();
573 assert_eq!(store.active_profile_id(), "bob");
574
575 assert!(matches!(
577 store.switch_profile("nobody"),
578 Err(ProfileError::NotFound(_))
579 ));
580
581 cleanup(&dir);
582 }
583
584 #[test]
585 fn delete_profile() {
586 let (store, dir) = make_store("delete");
587 store.create_profile("alice", "Alice").unwrap();
588 store.create_profile("bob", "Bob").unwrap();
589
590 assert!(matches!(
592 store.delete_profile("nobody"),
593 Err(ProfileError::NotFound(_))
594 ));
595
596 store.switch_profile("alice").unwrap();
598 assert!(store.delete_profile("alice").is_err());
599
600 store.delete_profile("bob").unwrap();
602 assert_eq!(store.len(), 1);
603
604 cleanup(&dir);
605 }
606
607 #[test]
608 fn insert_and_search_active_profile() {
609 let (store, dir) = make_store("insert_search");
610 store.create_profile("alice", "Alice").unwrap();
611 store.switch_profile("alice").unwrap();
612
613 store
614 .insert(1, "v1", &[1.0, 0.0, 0.0], serde_json::json!({}))
615 .unwrap();
616 store
617 .insert(2, "v2", &[0.0, 1.0, 0.0], serde_json::json!({}))
618 .unwrap();
619
620 let results = store.search(&[1.0, 0.0, 0.0], 1);
621 assert_eq!(results.len(), 1);
622 assert_eq!(results[0].key, "v1");
623
624 cleanup(&dir);
625 }
626
627 #[test]
628 fn profiles_are_isolated() {
629 let (store, dir) = make_store("isolated");
630 store.create_profile("alice", "Alice").unwrap();
631 store.create_profile("bob", "Bob").unwrap();
632
633 store.switch_profile("alice").unwrap();
635 store
636 .insert(1, "alice-vec", &[1.0, 0.0], serde_json::json!({}))
637 .unwrap();
638
639 store.switch_profile("bob").unwrap();
641 store
642 .insert(1, "bob-vec", &[0.0, 1.0], serde_json::json!({}))
643 .unwrap();
644
645 store.switch_profile("alice").unwrap();
647 let results = store.search(&[1.0, 0.0], 1);
648 assert_eq!(results.len(), 1);
649 assert_eq!(results[0].key, "alice-vec");
650
651 store.switch_profile("bob").unwrap();
653 let results = store.search(&[0.0, 1.0], 1);
654 assert_eq!(results.len(), 1);
655 assert_eq!(results[0].key, "bob-vec");
656
657 cleanup(&dir);
658 }
659
660 #[test]
661 fn load_existing_creates_default() {
662 let (store, dir) = make_store("load_default");
663 let count = store.load_existing().unwrap();
664 assert_eq!(count, 1);
665 assert!(store.profiles.contains_key("default"));
666 cleanup(&dir);
667 }
668
669 #[test]
670 fn load_existing_reads_persisted() {
671 let dir = tmp_dir("load_persisted");
672
673 {
675 let store = ProfileStore::new(dir.clone(), HnswServiceConfig::default());
676 store.create_profile("alice", "Alice").unwrap();
677 store.create_profile("bob", "Bob").unwrap();
678 store.persist_meta("alice").unwrap();
679 store.persist_meta("bob").unwrap();
680 }
681
682 {
684 let store = ProfileStore::new(dir.clone(), HnswServiceConfig::default());
685 let count = store.load_existing().unwrap();
686 assert_eq!(count, 2);
687 assert!(store.profiles.contains_key("alice"));
688 assert!(store.profiles.contains_key("bob"));
689 }
690
691 cleanup(&dir);
692 }
693
694 #[test]
695 fn persist_meta_updates_vector_count() {
696 let (store, dir) = make_store("persist_count");
697 store.create_profile("alice", "Alice").unwrap();
698 store.switch_profile("alice").unwrap();
699 store
700 .insert(1, "v1", &[1.0, 0.0], serde_json::json!({}))
701 .unwrap();
702 store.persist_meta("alice").unwrap();
703
704 let meta_path = dir.join("alice").join("profile.json");
706 let data = std::fs::read_to_string(&meta_path).unwrap();
707 let meta: ProfileMeta = serde_json::from_str(&data).unwrap();
708 assert_eq!(meta.vector_count, 1);
709
710 cleanup(&dir);
711 }
712}