1use crate::config::Config;
57use crate::context::GitContext;
58use crate::embedding::Embedder;
59use crate::gc::{ExpirationConfig, ExpirationService};
60use crate::models::{
61 CaptureRequest, CaptureResult, EventMeta, Memory, MemoryEvent, MemoryId, MemoryStatus,
62};
63use crate::observability::current_request_id;
64use crate::security::{ContentRedactor, SecretDetector, record_event};
65use crate::services::deduplication::ContentHasher;
66use crate::storage::index::{SqliteBackend, get_user_data_dir};
67use crate::storage::traits::{IndexBackend, VectorBackend};
68use crate::{Error, Result};
69use std::path::Path;
70use std::sync::Arc;
71use std::time::{Instant, SystemTime, UNIX_EPOCH};
72use tracing::{info_span, instrument};
73
74pub type EntityExtractionCallback =
80 Arc<dyn Fn(&str, &MemoryId) -> Result<EntityExtractionStats> + Send + Sync>;
81
82#[derive(Debug, Clone, Default)]
84pub struct EntityExtractionStats {
85 pub entities_stored: usize,
87 pub relationships_stored: usize,
89 pub used_fallback: bool,
91}
92
93fn run_entity_extraction(callback: &EntityExtractionCallback, content: &str, memory_id: &MemoryId) {
98 let _span = info_span!(
99 "subcog.memory.capture.entity_extraction",
100 memory_id = %memory_id
101 )
102 .entered();
103
104 match callback(content, memory_id) {
105 Ok(stats) => {
106 tracing::debug!(
107 memory_id = %memory_id,
108 entities = stats.entities_stored,
109 relationships = stats.relationships_stored,
110 fallback = stats.used_fallback,
111 "Entity extraction completed"
112 );
113 metrics::counter!(
114 "entity_extraction_total",
115 "status" => "success",
116 "fallback" => if stats.used_fallback { "true" } else { "false" }
117 )
118 .increment(1);
119 },
120 Err(e) => {
121 tracing::warn!(
122 memory_id = %memory_id,
123 error = %e,
124 "Entity extraction failed"
125 );
126 metrics::counter!("entity_extraction_total", "status" => "error").increment(1);
127 },
128 }
129}
130
131pub struct CaptureService {
158 config: Config,
160 secret_detector: SecretDetector,
162 redactor: ContentRedactor,
164 embedder: Option<Arc<dyn Embedder>>,
166 index: Option<Arc<dyn IndexBackend + Send + Sync>>,
168 vector: Option<Arc<dyn VectorBackend + Send + Sync>>,
170 entity_extraction: Option<EntityExtractionCallback>,
172 expiration_config: Option<ExpirationConfig>,
177 org_index: Option<Arc<dyn IndexBackend + Send + Sync>>,
182}
183
184impl CaptureService {
185 #[must_use]
206 pub fn new(config: Config) -> Self {
207 let index = Self::try_init_sqlite_backend(config.data_dir.as_deref());
208
209 Self {
210 config,
211 secret_detector: SecretDetector::new(),
212 redactor: ContentRedactor::new(),
213 embedder: None,
214 index,
215 vector: None,
216 entity_extraction: None,
217 expiration_config: None,
218 org_index: None,
219 }
220 }
221
222 #[must_use]
230 pub fn new_minimal(config: Config) -> Self {
231 Self {
232 config,
233 secret_detector: SecretDetector::new(),
234 redactor: ContentRedactor::new(),
235 embedder: None,
236 index: None,
237 vector: None,
238 entity_extraction: None,
239 expiration_config: None,
240 org_index: None,
241 }
242 }
243
244 fn try_init_sqlite_backend(
248 config_data_dir: Option<&Path>,
249 ) -> Option<Arc<dyn IndexBackend + Send + Sync>> {
250 let data_dir = match config_data_dir {
253 Some(dir) => dir.to_path_buf(),
254 None => match get_user_data_dir() {
255 Ok(dir) => dir,
256 Err(e) => {
257 tracing::warn!(
258 "CaptureService: unable to get user data dir ({e}) - captures will not persist"
259 );
260 return None;
261 },
262 },
263 };
264
265 if let Err(e) = std::fs::create_dir_all(&data_dir) {
266 tracing::warn!(
267 "CaptureService: unable to create data dir ({e}) - captures will not persist"
268 );
269 return None;
270 }
271
272 let db_path = data_dir.join("index.db");
273 match SqliteBackend::new(&db_path) {
274 Ok(backend) => Some(Arc::new(backend)),
275 Err(e) => {
276 tracing::warn!(
277 "CaptureService: unable to open SQLite backend ({e}) - captures will not persist"
278 );
279 None
280 },
281 }
282 }
283
284 #[must_use]
291 pub fn with_backends(
292 config: Config,
293 embedder: Arc<dyn Embedder>,
294 index: Arc<dyn IndexBackend + Send + Sync>,
295 vector: Arc<dyn VectorBackend + Send + Sync>,
296 ) -> Self {
297 Self {
298 config,
299 secret_detector: SecretDetector::new(),
300 redactor: ContentRedactor::new(),
301 embedder: Some(embedder),
302 index: Some(index),
303 vector: Some(vector),
304 entity_extraction: None,
305 expiration_config: None,
306 org_index: None,
307 }
308 }
309
310 #[must_use]
312 pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
313 self.embedder = Some(embedder);
314 self
315 }
316
317 #[must_use]
319 pub fn with_index(mut self, index: Arc<dyn IndexBackend + Send + Sync>) -> Self {
320 self.index = Some(index);
321 self
322 }
323
324 #[must_use]
326 pub fn with_vector(mut self, vector: Arc<dyn VectorBackend + Send + Sync>) -> Self {
327 self.vector = Some(vector);
328 self
329 }
330
331 #[must_use]
336 pub fn with_org_index(mut self, index: Arc<dyn IndexBackend + Send + Sync>) -> Self {
337 self.org_index = Some(index);
338 self
339 }
340
341 #[must_use]
343 pub fn has_org_index(&self) -> bool {
344 self.org_index.is_some()
345 }
346
347 #[must_use]
375 pub fn with_entity_extraction(mut self, callback: EntityExtractionCallback) -> Self {
376 self.entity_extraction = Some(callback);
377 self
378 }
379
380 #[must_use]
382 pub fn has_entity_extraction(&self) -> bool {
383 self.entity_extraction.is_some()
384 }
385
386 #[must_use]
388 pub fn has_embedder(&self) -> bool {
389 self.embedder.is_some()
390 }
391
392 #[must_use]
394 pub fn has_index(&self) -> bool {
395 self.index.is_some()
396 }
397
398 #[must_use]
400 pub fn has_vector(&self) -> bool {
401 self.vector.is_some()
402 }
403
404 #[must_use]
426 pub const fn with_expiration_config(mut self, config: ExpirationConfig) -> Self {
427 self.expiration_config = Some(config);
428 self
429 }
430
431 #[must_use]
433 pub const fn has_expiration(&self) -> bool {
434 self.expiration_config.is_some()
435 }
436
437 #[instrument(
471 name = "subcog.memory.capture",
472 skip(self, request),
473 fields(
474 request_id = tracing::field::Empty,
475 component = "memory",
476 operation = "capture",
477 namespace = %request.namespace,
478 domain = %request.domain,
479 content_length = request.content.len(),
480 skip_security_check = request.skip_security_check,
481 memory.id = tracing::field::Empty
482 )
483 )]
484 #[allow(clippy::too_many_lines)]
485 pub fn capture(&self, request: CaptureRequest) -> Result<CaptureResult> {
486 let start = Instant::now();
487 let namespace_label = request.namespace.as_str().to_string();
488 let domain_label = request.domain.to_string();
489 if let Some(request_id) = current_request_id() {
490 tracing::Span::current().record("request_id", request_id.as_str());
491 }
492
493 tracing::info!(namespace = %namespace_label, domain = %domain_label, "Capturing memory");
494
495 const MAX_CONTENT_SIZE: usize = 500_000;
497
498 let result = (|| {
499 let has_secrets = {
500 let _span = info_span!("subcog.memory.capture.validate").entered();
501 if request.content.trim().is_empty() {
503 return Err(Error::InvalidInput("Content cannot be empty".to_string()));
504 }
505 if request.content.len() > MAX_CONTENT_SIZE {
506 return Err(Error::InvalidInput(format!(
507 "Content exceeds maximum size of {} bytes (got {} bytes)",
508 MAX_CONTENT_SIZE,
509 request.content.len()
510 )));
511 }
512
513 let has_secrets = self.secret_detector.contains_secrets(&request.content);
515 if has_secrets && self.config.features.block_secrets && !request.skip_security_check
516 {
517 return Err(Error::ContentBlocked {
518 reason: "Content contains detected secrets".to_string(),
519 });
520 }
521 has_secrets
522 };
523
524 let (content, was_redacted) = {
526 let _span = info_span!("subcog.memory.capture.redact").entered();
527 if has_secrets
528 && self.config.features.redact_secrets
529 && !request.skip_security_check
530 {
531 (self.redactor.redact(&request.content), true)
532 } else {
533 (request.content.clone(), false)
534 }
535 };
536
537 let now = SystemTime::now()
539 .duration_since(UNIX_EPOCH)
540 .map(|d| d.as_secs())
541 .unwrap_or(0);
542
543 let uuid = uuid::Uuid::new_v4();
545 let memory_id = MemoryId::new(uuid.to_string().replace('-', "")[..12].to_string());
546
547 let span = tracing::Span::current();
548 span.record("memory.id", memory_id.as_str());
549
550 let embedding = {
552 let _span = info_span!("subcog.memory.capture.embed").entered();
553 if let Some(ref embedder) = self.embedder {
554 match embedder.embed(&content) {
555 Ok(emb) => {
556 tracing::debug!(
557 memory_id = %memory_id,
558 dimensions = emb.len(),
559 "Generated embedding for memory"
560 );
561 Some(emb)
562 },
563 Err(e) => {
564 tracing::warn!(
565 memory_id = %memory_id,
566 error = %e,
567 "Failed to generate embedding (continuing without)"
568 );
569 None
570 },
571 }
572 } else {
573 None
574 }
575 };
576
577 let git_context = self
579 .config
580 .repo_path
581 .as_ref()
582 .map_or_else(GitContext::from_cwd, |path| GitContext::from_path(path));
583
584 let file_path =
585 resolve_file_path(self.config.repo_path.as_deref(), request.source.as_ref());
586
587 let mut tags = request.tags;
588 let hash_tag = ContentHasher::content_to_tag(&content);
589 if !tags.iter().any(|tag| tag == &hash_tag) {
590 tags.push(hash_tag);
591 }
592
593 let expires_at = request.ttl_seconds.and_then(|ttl| {
597 if ttl == 0 {
598 None } else {
600 Some(now.saturating_add(ttl))
601 }
602 });
603
604 let mut memory = Memory {
606 id: memory_id.clone(),
607 content,
608 namespace: request.namespace,
609 domain: request.domain,
610 project_id: git_context.project_id,
611 branch: git_context.branch,
612 file_path,
613 status: MemoryStatus::Active,
614 created_at: now,
615 updated_at: now,
616 tombstoned_at: None,
617 expires_at,
618 embedding: embedding.clone(),
619 tags,
620 #[cfg(feature = "group-scope")]
621 group_id: request.group_id,
622 source: request.source,
623 is_summary: false,
624 source_memory_ids: None,
625 consolidation_timestamp: None,
626 };
627
628 let urn = self.generate_urn(&memory);
630
631 let mut warnings = Vec::new();
633 if was_redacted {
634 warnings.push("Content was redacted due to detected secrets".to_string());
635 }
636
637 if let Some(ref index) = self.index {
639 let _span = info_span!("subcog.memory.capture.index").entered();
640 let index_clone = Arc::clone(index);
642 match index_clone.index(&memory) {
644 Ok(()) => {
645 tracing::debug!(memory_id = %memory_id, "Indexed memory for text search");
646 },
647 Err(e) => {
648 tracing::warn!(
649 memory_id = %memory_id,
650 error = %e,
651 "Failed to index memory (continuing without)"
652 );
653 warnings.push("Memory not indexed for text search".to_string());
654 },
655 }
656 }
657
658 if let (Some(vector), Some(emb)) = (&self.vector, &embedding) {
660 let _span = info_span!("subcog.memory.capture.vector").entered();
661 let vector_clone = Arc::clone(vector);
663 match vector_clone.upsert(&memory_id, emb) {
664 Ok(()) => {
665 tracing::debug!(memory_id = %memory_id, "Upserted embedding to vector store");
666 },
667 Err(e) => {
668 tracing::warn!(
669 memory_id = %memory_id,
670 error = %e,
671 "Failed to upsert embedding (continuing without)"
672 );
673 warnings.push("Embedding not stored in vector index".to_string());
674 },
675 }
676 }
677
678 if self.config.features.auto_extract_entities
681 && let Some(ref callback) = self.entity_extraction
682 {
683 let callback = Arc::clone(callback);
684 let content = memory.content.clone();
685 let memory_id_for_task = memory_id.clone();
686
687 if tokio::runtime::Handle::try_current().is_ok() {
689 tokio::spawn(async move {
691 run_entity_extraction(&callback, &content, &memory_id_for_task);
692 });
693 } else {
694 run_entity_extraction(&callback, &content, &memory_id_for_task);
696 }
697 }
698
699 memory.embedding = None;
701
702 record_event(MemoryEvent::Captured {
703 meta: EventMeta::with_timestamp("capture", current_request_id(), now),
704 memory_id: memory_id.clone(),
705 namespace: memory.namespace,
706 domain: memory.domain.clone(),
707 content_length: memory.content.len(),
708 });
709 if was_redacted {
710 record_event(MemoryEvent::Redacted {
711 meta: EventMeta::with_timestamp("capture", current_request_id(), now),
712 memory_id: memory_id.clone(),
713 redaction_type: "secrets".to_string(),
714 });
715 }
716
717 Ok(CaptureResult {
718 memory_id,
719 urn,
720 content_modified: was_redacted,
721 warnings,
722 })
723 })();
724
725 let status = if result.is_ok() { "success" } else { "error" };
726 metrics::counter!(
727 "memory_operations_total",
728 "operation" => "capture",
729 "namespace" => namespace_label.clone(),
730 "domain" => domain_label,
731 "status" => status
732 )
733 .increment(1);
734 metrics::histogram!(
735 "memory_operation_duration_ms",
736 "operation" => "capture",
737 "namespace" => namespace_label
738 )
739 .record(start.elapsed().as_secs_f64() * 1000.0);
740 metrics::histogram!(
741 "memory_lifecycle_duration_ms",
742 "component" => "memory",
743 "operation" => "capture"
744 )
745 .record(start.elapsed().as_secs_f64() * 1000.0);
746
747 if result.is_ok() {
749 self.maybe_run_expiration_cleanup();
750 }
751
752 result
753 }
754
755 fn maybe_run_expiration_cleanup(&self) {
760 let (Some(config), Some(index)) = (&self.expiration_config, &self.index) else {
762 return;
763 };
764
765 let service = ExpirationService::new(Arc::clone(index), config.clone());
767
768 if !service.should_run_cleanup() {
769 return;
770 }
771
772 let _span = info_span!("subcog.memory.capture.expiration_cleanup").entered();
774 match service.gc_expired_memories(false) {
775 Ok(result) => {
776 if result.memories_tombstoned > 0 {
777 tracing::info!(
778 tombstoned = result.memories_tombstoned,
779 checked = result.memories_checked,
780 duration_ms = result.duration_ms,
781 "Probabilistic TTL cleanup completed"
782 );
783 } else {
784 tracing::debug!(
785 checked = result.memories_checked,
786 duration_ms = result.duration_ms,
787 "Probabilistic TTL cleanup found no expired memories"
788 );
789 }
790 },
791 Err(e) => {
792 tracing::warn!(
793 error = %e,
794 "Probabilistic TTL cleanup failed (capture still succeeded)"
795 );
796 },
797 }
798 }
799
800 #[allow(clippy::unused_self)] fn generate_urn(&self, memory: &Memory) -> String {
803 let domain_part = if memory.domain.is_project_scoped() {
804 "project".to_string()
805 } else {
806 memory.domain.to_string()
807 };
808
809 format!(
810 "subcog://{}/{}/{}",
811 domain_part,
812 memory.namespace.as_str(),
813 memory.id.as_str()
814 )
815 }
816
817 pub fn validate(&self, request: &CaptureRequest) -> Result<ValidationResult> {
863 let mut issues = Vec::new();
864 let mut warnings = Vec::new();
865
866 if request.content.trim().is_empty() {
868 issues.push("Content cannot be empty".to_string());
869 } else if request.content.len() > 100_000 {
870 warnings.push("Content is very long (>100KB)".to_string());
871 }
872
873 let secrets = self.secret_detector.detect_types(&request.content);
875 if !secrets.is_empty() {
876 if self.config.features.block_secrets {
877 issues.push(format!("Content contains secrets: {}", secrets.join(", ")));
878 } else {
879 warnings.push(format!("Content contains secrets: {}", secrets.join(", ")));
880 }
881 }
882
883 Ok(ValidationResult {
884 is_valid: issues.is_empty(),
885 issues,
886 warnings,
887 })
888 }
889
890 pub fn capture_authorized(
909 &self,
910 request: CaptureRequest,
911 auth: &super::auth::AuthContext,
912 ) -> Result<CaptureResult> {
913 auth.require(super::auth::Permission::Write)?;
914
915 #[cfg(feature = "group-scope")]
917 if let Some(ref group_id) = request.group_id {
918 use crate::models::group::GroupRole;
919 auth.require_group_role(group_id, GroupRole::Write)?;
920 }
921
922 self.capture(request)
923 }
924}
925
926fn resolve_file_path(repo_root: Option<&Path>, source: Option<&String>) -> Option<String> {
927 let source = source?;
928 if source.contains("://") {
929 return None;
930 }
931
932 let source_path = Path::new(source);
933 let repo_root = repo_root?;
934
935 if let Ok(relative) = source_path.strip_prefix(repo_root) {
936 return Some(normalize_path(&relative.to_string_lossy()));
937 }
938
939 if source_path.is_relative() {
940 return Some(normalize_path(source));
941 }
942
943 None
944}
945
946fn normalize_path(path: &str) -> String {
947 path.replace('\\', "/")
948}
949
950impl Default for CaptureService {
951 fn default() -> Self {
952 Self::new(Config::default())
953 }
954}
955
956#[derive(Debug, Clone)]
958pub struct ValidationResult {
959 pub is_valid: bool,
961 pub issues: Vec<String>,
963 pub warnings: Vec<String>,
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use crate::models::{Domain, Namespace};
971
972 fn test_config() -> Config {
973 Config::default()
974 }
975
976 fn test_request(content: &str) -> CaptureRequest {
977 CaptureRequest {
978 content: content.to_string(),
979 namespace: Namespace::Decisions,
980 domain: Domain::default(),
981 tags: vec!["test".to_string()],
982 source: Some("test.rs".to_string()),
983 skip_security_check: false,
984 ttl_seconds: None,
985 scope: None,
986 #[cfg(feature = "group-scope")]
987 group_id: None,
988 }
989 }
990
991 #[test]
992 fn test_capture_success() {
993 let service = CaptureService::new(test_config());
994 let request = test_request("Use PostgreSQL for primary storage");
995
996 let result = service.capture(request);
997 assert!(result.is_ok());
998
999 let result = result.unwrap();
1000 assert_eq!(result.memory_id.as_str().len(), 12);
1002 assert!(
1003 result
1004 .memory_id
1005 .as_str()
1006 .chars()
1007 .all(|c| c.is_ascii_hexdigit())
1008 );
1009 assert!(result.urn.starts_with("subcog://"));
1010 assert!(!result.content_modified);
1011 }
1012
1013 #[test]
1014 fn test_capture_empty_content() {
1015 let service = CaptureService::new(test_config());
1016 let request = test_request(" ");
1017
1018 let result = service.capture(request);
1019 assert!(result.is_err());
1020 assert!(matches!(result, Err(Error::InvalidInput(_))));
1021 }
1022
1023 #[test]
1024 fn test_capture_with_secrets_redacted() {
1025 let mut config = test_config();
1026 config.features.redact_secrets = true;
1027 config.features.block_secrets = false;
1028
1029 let service = CaptureService::new(config);
1030 let request = test_request("My API key is AKIAIOSFODNN7EXAMPLE");
1031
1032 let result = service.capture(request);
1033 assert!(result.is_ok());
1034 assert!(result.unwrap().content_modified);
1035 }
1036
1037 #[test]
1038 fn test_capture_with_secrets_blocked() {
1039 let mut config = test_config();
1040 config.features.block_secrets = true;
1041
1042 let service = CaptureService::new(config);
1043 let request = test_request("My API key is AKIAIOSFODNN7EXAMPLE");
1044
1045 let result = service.capture(request);
1046 assert!(result.is_err());
1047 assert!(matches!(result, Err(Error::ContentBlocked { .. })));
1048 }
1049
1050 #[test]
1051 fn test_validate_valid() {
1052 let service = CaptureService::new(test_config());
1053 let request = test_request("Valid content");
1054
1055 let result = service.validate(&request).unwrap();
1056 assert!(result.is_valid);
1057 assert!(result.issues.is_empty());
1058 }
1059
1060 #[test]
1061 fn test_validate_empty() {
1062 let service = CaptureService::new(test_config());
1063 let request = test_request("");
1064
1065 let result = service.validate(&request).unwrap();
1066 assert!(!result.is_valid);
1067 assert!(!result.issues.is_empty());
1068 }
1069
1070 #[test]
1071 fn test_generate_urn() {
1072 let service = CaptureService::new(test_config());
1073
1074 let memory = Memory {
1075 id: MemoryId::new("test_123"),
1076 content: "Test".to_string(),
1077 namespace: Namespace::Decisions,
1078 domain: Domain::for_repository("zircote", "subcog"),
1079 project_id: None,
1080 branch: None,
1081 file_path: None,
1082 status: MemoryStatus::Active,
1083 created_at: 0,
1084 updated_at: 0,
1085 tombstoned_at: None,
1086 expires_at: None,
1087 embedding: None,
1088 tags: vec![],
1089 #[cfg(feature = "group-scope")]
1090 group_id: None,
1091 source: None,
1092 is_summary: false,
1093 source_memory_ids: None,
1094 consolidation_timestamp: None,
1095 };
1096
1097 let urn = service.generate_urn(&memory);
1098 assert!(urn.contains("subcog"));
1099 assert!(urn.contains("decisions"));
1100 assert!(urn.contains("test_123"));
1101 }
1102
1103 use crate::embedding::FastEmbedEmbedder;
1108 use crate::services::deduplication::ContentHasher;
1109 use crate::storage::index::SqliteBackend;
1110 use crate::storage::vector::UsearchBackend;
1111 use git2::{Repository, Signature};
1112 use tempfile::TempDir;
1113
1114 fn init_test_repo() -> (TempDir, Repository) {
1115 let dir = TempDir::new().expect("Failed to create temp dir");
1116 let repo = Repository::init(dir.path()).expect("Failed to init repo");
1117
1118 let sig = Signature::now("test", "test@test.com").expect("Failed to create signature");
1119 let tree_id = repo
1120 .index()
1121 .expect("Failed to get index")
1122 .write_tree()
1123 .expect("Failed to write tree");
1124 {
1125 let tree = repo.find_tree(tree_id).expect("Failed to find tree");
1126 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1127 .expect("Failed to create commit");
1128 }
1129 repo.remote("origin", "https://github.com/org/repo.git")
1130 .expect("Failed to add remote");
1131
1132 (dir, repo)
1133 }
1134
1135 #[test]
1136 fn test_capture_with_embedder_generates_embedding() {
1137 let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1139 let service = CaptureService::new(test_config()).with_embedder(embedder);
1140
1141 assert!(service.has_embedder());
1142 }
1145
1146 #[test]
1147 fn test_capture_with_index_backend() {
1148 let index = SqliteBackend::in_memory().expect("Failed to create in-memory SQLite backend");
1150 let index_arc: Arc<dyn IndexBackend + Send + Sync> = Arc::new(index);
1151
1152 let service = CaptureService::new(test_config()).with_index(index_arc);
1153
1154 assert!(service.has_index());
1155 let request = test_request("Use PostgreSQL for primary storage");
1157 let result = service.capture(request);
1158 assert!(result.is_ok());
1159 }
1160
1161 #[test]
1162 fn test_capture_sets_facets_and_hash_tag() {
1163 let (dir, _repo) = init_test_repo();
1164 let repo_path = dir.path();
1165 let index: Arc<dyn IndexBackend + Send + Sync> =
1166 Arc::new(SqliteBackend::in_memory().unwrap());
1167 let config = Config::new().with_repo_path(repo_path);
1168 let service = CaptureService::new(config).with_index(Arc::clone(&index));
1169
1170 let file_path = repo_path.join("src").join("lib.rs");
1171 std::fs::create_dir_all(file_path.parent().expect("parent path")).expect("create dir");
1172 std::fs::write(&file_path, "fn main() {}\n").expect("write file");
1173
1174 let request = CaptureRequest {
1175 content: "Test content for facets".to_string(),
1176 namespace: Namespace::Decisions,
1177 domain: Domain::new(),
1178 tags: vec!["test".to_string()],
1179 source: Some(file_path.to_string_lossy().to_string()),
1180 skip_security_check: false,
1181 ttl_seconds: None,
1182 scope: None,
1183 #[cfg(feature = "group-scope")]
1184 group_id: None,
1185 };
1186
1187 let result = service.capture(request).expect("capture");
1188 let stored = index
1189 .get_memory(&result.memory_id)
1190 .expect("get memory")
1191 .expect("stored memory");
1192
1193 assert_eq!(stored.project_id.as_deref(), Some("github.com/org/repo"));
1194 assert!(stored.branch.is_some());
1195 assert_eq!(stored.file_path.as_deref(), Some("src/lib.rs"));
1196
1197 let hash_tag = ContentHasher::content_to_tag(&stored.content);
1198 assert!(stored.tags.contains(&hash_tag));
1199 }
1200
1201 #[test]
1202 fn test_capture_with_vector_backend() {
1203 #[cfg(not(feature = "usearch-hnsw"))]
1206 let vector = UsearchBackend::new(
1207 std::env::temp_dir().join("test_vector_capture"),
1208 FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1209 );
1210 #[cfg(feature = "usearch-hnsw")]
1211 let vector = UsearchBackend::new(
1212 std::env::temp_dir().join("test_vector_capture"),
1213 FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1214 )
1215 .expect("Failed to create usearch backend");
1216
1217 let vector_arc: Arc<dyn VectorBackend + Send + Sync> = Arc::new(vector);
1218
1219 let service = CaptureService::new(test_config()).with_vector(vector_arc);
1220
1221 assert!(service.has_vector());
1222 }
1224
1225 #[test]
1226 fn test_capture_with_all_backends() {
1227 let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1229 let index = SqliteBackend::in_memory().expect("Failed to create in-memory SQLite backend");
1230 let index_arc: Arc<dyn IndexBackend + Send + Sync> = Arc::new(index);
1231
1232 #[cfg(not(feature = "usearch-hnsw"))]
1233 let vector = UsearchBackend::new(
1234 std::env::temp_dir().join("test_vector_all"),
1235 FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1236 );
1237 #[cfg(feature = "usearch-hnsw")]
1238 let vector = UsearchBackend::new(
1239 std::env::temp_dir().join("test_vector_all"),
1240 FastEmbedEmbedder::DEFAULT_DIMENSIONS,
1241 )
1242 .expect("Failed to create usearch backend");
1243
1244 let vector_arc: Arc<dyn VectorBackend + Send + Sync> = Arc::new(vector);
1245
1246 let service = CaptureService::with_backends(
1247 test_config(),
1248 Arc::clone(&embedder),
1249 Arc::clone(&index_arc),
1250 Arc::clone(&vector_arc),
1251 );
1252
1253 assert!(service.has_embedder());
1254 assert!(service.has_index());
1255 assert!(service.has_vector());
1256
1257 let request = test_request("Use PostgreSQL for primary storage");
1259 let result = service.capture(request);
1260 assert!(result.is_ok(), "Capture failed: {:?}", result.err());
1261 }
1262
1263 #[test]
1264 fn test_capture_succeeds_without_backends() {
1265 let service = CaptureService::new_minimal(test_config());
1267
1268 assert!(!service.has_embedder());
1269 assert!(!service.has_index());
1270 assert!(!service.has_vector());
1271
1272 let request = test_request("This should still work");
1273 let result = service.capture(request);
1274 assert!(result.is_ok(), "Capture should succeed without backends");
1275 }
1276
1277 #[test]
1278 fn test_capture_succeeds_with_only_embedder() {
1279 let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1281 let service = CaptureService::new_minimal(test_config()).with_embedder(embedder);
1282
1283 assert!(service.has_embedder());
1284 assert!(!service.has_index());
1285 assert!(!service.has_vector());
1286
1287 let request = test_request("Test with embedder only");
1288 let result = service.capture(request);
1289 assert!(result.is_ok(), "Capture should succeed with embedder only");
1290 }
1291
1292 #[test]
1293 fn test_capture_index_failure_doesnt_fail_capture() {
1294 let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1300 let service = CaptureService::new(test_config()).with_embedder(embedder);
1301
1302 let request = test_request("Test graceful degradation");
1303 let result = service.capture(request);
1304 assert!(result.is_ok());
1305 }
1306
1307 #[test]
1308 fn test_has_embedder_returns_false_when_not_configured() {
1309 let service = CaptureService::new(test_config());
1310 assert!(!service.has_embedder());
1311 }
1312
1313 #[test]
1314 fn test_has_index_returns_false_when_not_configured() {
1315 let service = CaptureService::new_minimal(test_config());
1317 assert!(!service.has_index());
1318 }
1319
1320 #[test]
1321 fn test_new_auto_initializes_index() {
1322 let service = CaptureService::new(test_config());
1324 assert!(
1327 service.has_index(),
1328 "CaptureService::new() should auto-initialize SQLite backend"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_has_vector_returns_false_when_not_configured() {
1334 let service = CaptureService::new(test_config());
1335 assert!(!service.has_vector());
1336 }
1337
1338 #[test]
1339 fn test_builder_methods_chain() {
1340 let embedder: Arc<dyn Embedder> = Arc::new(FastEmbedEmbedder::new());
1342 let index = SqliteBackend::in_memory().expect("Failed to create in-memory SQLite backend");
1343 let index_arc: Arc<dyn IndexBackend + Send + Sync> = Arc::new(index);
1344
1345 let service = CaptureService::new(test_config())
1346 .with_embedder(embedder)
1347 .with_index(index_arc);
1348
1349 assert!(service.has_embedder());
1350 assert!(service.has_index());
1351 assert!(!service.has_vector()); }
1353
1354 #[cfg(feature = "group-scope")]
1359 mod group_scope_tests {
1360 use super::*;
1361 use crate::services::auth::AuthContext;
1362
1363 fn test_config() -> Config {
1364 Config::default()
1365 }
1366
1367 #[test]
1368 fn test_capture_with_group_id() {
1369 let index: Arc<dyn IndexBackend + Send + Sync> =
1370 Arc::new(SqliteBackend::in_memory().unwrap());
1371 let service = CaptureService::new(test_config()).with_index(Arc::clone(&index));
1372
1373 let request = CaptureRequest::new("Group-scoped memory content")
1374 .with_namespace(Namespace::Decisions)
1375 .with_group_id("team-alpha");
1376
1377 let result = service.capture(request).expect("capture should succeed");
1378
1379 let stored = index
1381 .get_memory(&result.memory_id)
1382 .expect("get memory")
1383 .expect("stored memory");
1384 assert_eq!(stored.group_id.as_deref(), Some("team-alpha"));
1385 }
1386
1387 #[test]
1388 fn test_capture_authorized_with_group_permission() {
1389 let service = CaptureService::new(test_config());
1390
1391 let auth = AuthContext::builder()
1393 .scope("write")
1394 .group_role("team-alpha", "write")
1395 .build();
1396
1397 let request = CaptureRequest::new("Group content")
1398 .with_namespace(Namespace::Decisions)
1399 .with_group_id("team-alpha");
1400
1401 let result = service.capture_authorized(request, &auth);
1402 assert!(result.is_ok(), "Should succeed with group permission");
1403 }
1404
1405 #[test]
1406 fn test_capture_authorized_fails_without_group_permission() {
1407 let service = CaptureService::new(test_config());
1408
1409 let auth = AuthContext::builder().scope("write").build();
1411
1412 let request = CaptureRequest::new("Group content")
1413 .with_namespace(Namespace::Decisions)
1414 .with_group_id("team-alpha");
1415
1416 let result = service.capture_authorized(request, &auth);
1417 assert!(result.is_err(), "Should fail without group permission");
1418 assert!(matches!(result, Err(Error::Unauthorized { .. })));
1419 }
1420
1421 #[test]
1422 fn test_capture_authorized_fails_with_read_only_group_permission() {
1423 let service = CaptureService::new(test_config());
1424
1425 let auth = AuthContext::builder()
1427 .scope("write")
1428 .group_role("team-alpha", "read")
1429 .build();
1430
1431 let request = CaptureRequest::new("Group content")
1432 .with_namespace(Namespace::Decisions)
1433 .with_group_id("team-alpha");
1434
1435 let result = service.capture_authorized(request, &auth);
1436 assert!(result.is_err(), "Should fail with read-only group access");
1437 }
1438
1439 #[test]
1440 fn test_capture_authorized_without_group_id_succeeds() {
1441 let service = CaptureService::new(test_config());
1442
1443 let auth = AuthContext::builder().scope("write").build();
1445
1446 let request =
1448 CaptureRequest::new("Non-group content").with_namespace(Namespace::Decisions);
1449
1450 let result = service.capture_authorized(request, &auth);
1451 assert!(result.is_ok(), "Should succeed without group_id");
1452 }
1453
1454 #[test]
1455 fn test_capture_request_builder_with_group_id() {
1456 let request = CaptureRequest::new("Content")
1457 .with_namespace(Namespace::Learnings)
1458 .with_tag("test")
1459 .with_group_id("my-group");
1460
1461 assert_eq!(request.group_id.as_deref(), Some("my-group"));
1462 assert_eq!(request.namespace, Namespace::Learnings);
1463 }
1464
1465 #[test]
1466 fn test_capture_with_local_auth_and_group_id() {
1467 let service = CaptureService::new(test_config());
1469 let auth = AuthContext::local();
1470
1471 let request = CaptureRequest::new("Local group content")
1472 .with_namespace(Namespace::Decisions)
1473 .with_group_id("any-group");
1474
1475 let result = service.capture_authorized(request, &auth);
1476 assert!(
1477 result.is_ok(),
1478 "Local auth should have implicit group access"
1479 );
1480 }
1481 }
1482}