subcog/services/
mod.rs

1//! Business logic services.
2//!
3//! Services orchestrate storage backends and provide high-level operations.
4
5// Allow cast_precision_loss for score calculations where exact precision is not critical.
6#![allow(clippy::cast_precision_loss)]
7// Allow option_if_let_else for clearer code in some contexts.
8#![allow(clippy::option_if_let_else)]
9// Allow significant_drop_tightening as dropping slightly early provides no benefit.
10#![allow(clippy::significant_drop_tightening)]
11// Allow unused_self for methods kept for API consistency.
12#![allow(clippy::unused_self)]
13// Allow trivially_copy_pass_by_ref for namespace references.
14#![allow(clippy::trivially_copy_pass_by_ref)]
15// Allow unnecessary_wraps for const fn methods returning Result.
16#![allow(clippy::unnecessary_wraps)]
17// Allow manual_let_else for clearer error handling patterns.
18#![allow(clippy::manual_let_else)]
19// Allow or_fun_call for entry API with closures.
20#![allow(clippy::or_fun_call)]
21
22mod capture;
23mod consolidation;
24mod context;
25mod enrichment;
26mod query_parser;
27mod recall;
28mod sync;
29mod topic_index;
30
31pub use capture::CaptureService;
32pub use consolidation::ConsolidationService;
33pub use context::{ContextBuilderService, MemoryStatistics};
34pub use enrichment::{EnrichmentResult, EnrichmentService, EnrichmentStats};
35pub use query_parser::parse_filter_query;
36pub use recall::RecallService;
37pub use sync::SyncService;
38pub use topic_index::{TopicIndexService, TopicInfo};
39
40use crate::git::{NotesManager, YamlFrontMatterParser};
41use crate::models::{Domain, Memory, MemoryId, MemoryStatus, Namespace};
42use crate::storage::index::{
43    DomainIndexConfig, DomainIndexManager, DomainScope, OrgIndexConfig, SqliteBackend,
44    find_repo_root,
45};
46use crate::storage::traits::IndexBackend;
47use crate::{Error, Result};
48use std::path::PathBuf;
49use std::sync::Mutex;
50
51/// Container for initialized services with configured backends.
52///
53/// Unlike the previous singleton design, this can be instantiated per-context
54/// with domain-scoped indices.
55pub struct ServiceContainer {
56    /// Capture service.
57    capture: CaptureService,
58    /// Sync service.
59    sync: SyncService,
60    /// Domain index manager for multi-domain indices.
61    index_manager: Mutex<DomainIndexManager>,
62    /// Repository path (if known).
63    repo_path: Option<PathBuf>,
64}
65
66impl ServiceContainer {
67    /// Creates a new service container for a repository.
68    ///
69    /// # Arguments
70    ///
71    /// * `repo_path` - Path to or within a git repository
72    /// * `org_config` - Optional organization index configuration
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the repository cannot be found or backends fail to initialize.
77    pub fn for_repo(
78        repo_path: impl Into<PathBuf>,
79        org_config: Option<OrgIndexConfig>,
80    ) -> Result<Self> {
81        let repo_path = repo_path.into();
82
83        // Find repository root
84        let repo_root = find_repo_root(&repo_path)?;
85
86        let config = DomainIndexConfig {
87            repo_path: Some(repo_root.clone()),
88            org_config,
89        };
90
91        let index_manager = DomainIndexManager::new(config)?;
92
93        // Create CaptureService with repo_path so it stores to git notes
94        let capture_config = crate::config::Config::new().with_repo_path(&repo_root);
95
96        Ok(Self {
97            capture: CaptureService::new(capture_config),
98            sync: SyncService::default(),
99            index_manager: Mutex::new(index_manager),
100            repo_path: Some(repo_root),
101        })
102    }
103
104    /// Creates a service container from the current directory.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if not in a git repository.
109    pub fn from_current_dir() -> Result<Self> {
110        let cwd = std::env::current_dir().map_err(|e| Error::OperationFailed {
111            operation: "get_current_dir".to_string(),
112            cause: e.to_string(),
113        })?;
114
115        Self::for_repo(cwd, None)
116    }
117
118    /// Creates a recall service for a specific domain scope.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the index cannot be initialized.
123    pub fn recall_for_scope(&self, scope: DomainScope) -> Result<RecallService> {
124        let manager = self
125            .index_manager
126            .lock()
127            .map_err(|e| Error::OperationFailed {
128                operation: "lock_index_manager".to_string(),
129                cause: e.to_string(),
130            })?;
131
132        let index_path = manager.get_index_path(scope)?;
133
134        // Ensure parent directory exists
135        if let Some(parent) = index_path.parent() {
136            std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
137                operation: "create_index_dir".to_string(),
138                cause: e.to_string(),
139            })?;
140        }
141
142        let index = SqliteBackend::new(&index_path)?;
143        Ok(RecallService::with_index(index))
144    }
145
146    /// Creates a recall service for the project scope.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the index cannot be initialized.
151    pub fn recall(&self) -> Result<RecallService> {
152        self.recall_for_scope(DomainScope::Project)
153    }
154
155    /// Returns the capture service.
156    #[must_use]
157    pub const fn capture(&self) -> &CaptureService {
158        &self.capture
159    }
160
161    /// Returns the sync service.
162    #[must_use]
163    pub const fn sync(&self) -> &SyncService {
164        &self.sync
165    }
166
167    /// Returns the repository path.
168    #[must_use]
169    pub const fn repo_path(&self) -> Option<&PathBuf> {
170        self.repo_path.as_ref()
171    }
172
173    /// Gets the index path for a domain scope.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the path cannot be determined.
178    pub fn get_index_path(&self, scope: DomainScope) -> Result<PathBuf> {
179        let manager = self
180            .index_manager
181            .lock()
182            .map_err(|e| Error::OperationFailed {
183                operation: "lock_index_manager".to_string(),
184                cause: e.to_string(),
185            })?;
186        manager.get_index_path(scope)
187    }
188
189    /// Reindexes memories from git notes into the index for a specific scope.
190    ///
191    /// # Arguments
192    ///
193    /// * `scope` - The domain scope to reindex
194    ///
195    /// # Returns
196    ///
197    /// The number of memories indexed.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if notes cannot be read or indexing fails.
202    pub fn reindex_scope(&self, scope: DomainScope) -> Result<usize> {
203        let repo_path = self
204            .repo_path
205            .as_ref()
206            .ok_or_else(|| Error::InvalidInput("Repository path not configured".to_string()))?;
207
208        let notes = NotesManager::new(repo_path);
209
210        // Get all notes
211        let all_notes = notes.list()?;
212
213        if all_notes.is_empty() {
214            return Ok(0);
215        }
216
217        // Parse notes into memories
218        let mut memories = Vec::with_capacity(all_notes.len());
219        for (note_id, content) in &all_notes {
220            match parse_note_to_memory(note_id, content) {
221                Ok(memory) => memories.push(memory),
222                Err(e) => {
223                    tracing::warn!("Failed to parse note {note_id}: {e}");
224                },
225            }
226        }
227
228        if memories.is_empty() {
229            return Ok(0);
230        }
231
232        // Get index path and create backend
233        let index_path = self.get_index_path(scope)?;
234
235        // Ensure parent directory exists
236        if let Some(parent) = index_path.parent() {
237            std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
238                operation: "create_index_dir".to_string(),
239                cause: e.to_string(),
240            })?;
241        }
242
243        let mut index = SqliteBackend::new(&index_path)?;
244
245        // Clear and reindex
246        index.clear()?;
247        let count = memories.len();
248        index.reindex(&memories)?;
249
250        Ok(count)
251    }
252
253    /// Reindexes memories for the project scope (default).
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if notes cannot be read or indexing fails.
258    pub fn reindex(&self) -> Result<usize> {
259        self.reindex_scope(DomainScope::Project)
260    }
261
262    /// Reindexes all domain scopes.
263    ///
264    /// # Returns
265    ///
266    /// A map of scope to count of indexed memories.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if any scope fails to reindex.
271    pub fn reindex_all(&self) -> Result<std::collections::HashMap<DomainScope, usize>> {
272        let mut results = std::collections::HashMap::new();
273
274        for scope in [DomainScope::Project, DomainScope::User, DomainScope::Org] {
275            match self.reindex_scope(scope) {
276                Ok(count) => {
277                    results.insert(scope, count);
278                },
279                Err(e) => {
280                    tracing::warn!("Failed to reindex scope {:?}: {e}", scope);
281                },
282            }
283        }
284
285        Ok(results)
286    }
287}
288
289// Legacy compatibility: Keep a global instance for backward compatibility
290use once_cell::sync::OnceCell;
291static LEGACY_SERVICES: OnceCell<LegacyServiceContainer> = OnceCell::new();
292
293/// Legacy service container for backward compatibility.
294///
295/// Deprecated: Use `ServiceContainer::for_repo()` instead.
296pub struct LegacyServiceContainer {
297    recall: RecallService,
298    capture: CaptureService,
299    sync: SyncService,
300    index: Mutex<SqliteBackend>,
301    data_dir: PathBuf,
302}
303
304impl LegacyServiceContainer {
305    /// Initializes the legacy service container.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if backends cannot be initialized.
310    #[deprecated(note = "Use ServiceContainer::for_repo() instead")]
311    pub fn init(data_dir: Option<PathBuf>) -> Result<&'static Self> {
312        LEGACY_SERVICES.get_or_try_init(|| {
313            let data_dir = data_dir.unwrap_or_else(|| {
314                directories::BaseDirs::new()
315                    .map_or_else(|| PathBuf::from("."), |b| b.data_local_dir().to_path_buf())
316                    .join("subcog")
317            });
318
319            std::fs::create_dir_all(&data_dir).map_err(|e| Error::OperationFailed {
320                operation: "create_data_dir".to_string(),
321                cause: e.to_string(),
322            })?;
323
324            let db_path = data_dir.join("index.db");
325            let index = SqliteBackend::new(&db_path)?;
326            let recall_index = SqliteBackend::new(&db_path)?;
327
328            Ok(Self {
329                recall: RecallService::with_index(recall_index),
330                capture: CaptureService::default(),
331                sync: SyncService::default(),
332                index: Mutex::new(index),
333                data_dir,
334            })
335        })
336    }
337
338    /// Gets the legacy service container.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if initialization fails.
343    #[deprecated(note = "Use ServiceContainer::for_repo() instead")]
344    #[allow(deprecated)]
345    pub fn get() -> Result<&'static Self> {
346        Self::init(None)
347    }
348
349    /// Returns the recall service.
350    #[must_use]
351    pub const fn recall(&self) -> &RecallService {
352        &self.recall
353    }
354
355    /// Returns the capture service.
356    #[must_use]
357    pub const fn capture(&self) -> &CaptureService {
358        &self.capture
359    }
360
361    /// Returns the sync service.
362    #[must_use]
363    pub const fn sync(&self) -> &SyncService {
364        &self.sync
365    }
366
367    /// Returns the data directory path.
368    #[must_use]
369    pub const fn data_dir(&self) -> &PathBuf {
370        &self.data_dir
371    }
372
373    /// Reindexes memories from git notes.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if notes cannot be read or indexing fails.
378    pub fn reindex(&self, repo_path: &std::path::Path) -> Result<usize> {
379        let notes = NotesManager::new(repo_path);
380        let all_notes = notes.list()?;
381
382        if all_notes.is_empty() {
383            return Ok(0);
384        }
385
386        let mut memories = Vec::with_capacity(all_notes.len());
387        for (note_id, content) in &all_notes {
388            match parse_note_to_memory(note_id, content) {
389                Ok(memory) => memories.push(memory),
390                Err(e) => {
391                    tracing::warn!("Failed to parse note {note_id}: {e}");
392                },
393            }
394        }
395
396        if memories.is_empty() {
397            return Ok(0);
398        }
399
400        let mut index = self.index.lock().map_err(|e| Error::OperationFailed {
401            operation: "lock_index".to_string(),
402            cause: e.to_string(),
403        })?;
404
405        index.clear()?;
406        let count = memories.len();
407        index.reindex(&memories)?;
408
409        Ok(count)
410    }
411}
412
413/// Parses a git note into a Memory object.
414///
415/// # Arguments
416///
417/// * `note_id` - The git commit OID the note is attached to (used as fallback ID)
418/// * `content` - The note content with optional YAML front matter
419///
420/// # Errors
421///
422/// Returns an error if the note cannot be parsed.
423fn parse_note_to_memory(note_id: &str, content: &str) -> Result<Memory> {
424    let (metadata, body) = YamlFrontMatterParser::parse(content)?;
425
426    // Extract fields from metadata, using defaults where necessary
427    let id = metadata
428        .get("id")
429        .and_then(|v| v.as_str())
430        .map_or_else(|| MemoryId::new(note_id), MemoryId::new);
431
432    let namespace = metadata
433        .get("namespace")
434        .and_then(|v| v.as_str())
435        .and_then(Namespace::parse)
436        .unwrap_or_default();
437
438    let domain = metadata
439        .get("domain")
440        .and_then(|v| v.as_str())
441        .map_or_else(Domain::new, parse_domain_string);
442
443    let status = metadata
444        .get("status")
445        .and_then(|v| v.as_str())
446        .map_or(MemoryStatus::Active, parse_status_string);
447
448    let created_at = metadata
449        .get("created_at")
450        .and_then(serde_json::Value::as_u64)
451        .unwrap_or(0);
452
453    let updated_at = metadata
454        .get("updated_at")
455        .and_then(serde_json::Value::as_u64)
456        .unwrap_or(created_at);
457
458    let tags = metadata
459        .get("tags")
460        .and_then(|v| v.as_array())
461        .map_or_else(Vec::new, |arr| {
462            arr.iter()
463                .filter_map(|v| v.as_str().map(String::from))
464                .collect()
465        });
466
467    let source = metadata
468        .get("source")
469        .and_then(|v| v.as_str())
470        .map(String::from);
471
472    Ok(Memory {
473        id,
474        content: body,
475        namespace,
476        domain,
477        status,
478        created_at,
479        updated_at,
480        embedding: None,
481        tags,
482        source,
483    })
484}
485
486/// Parses a domain string (e.g., "org/repo") into a Domain.
487fn parse_domain_string(s: &str) -> Domain {
488    if s == "global" || s.is_empty() {
489        return Domain::new();
490    }
491
492    let parts: Vec<&str> = s.split('/').collect();
493    match parts.len() {
494        1 => Domain {
495            organization: Some(parts[0].to_string()),
496            project: None,
497            repository: None,
498        },
499        2 => Domain {
500            organization: Some(parts[0].to_string()),
501            project: None,
502            repository: Some(parts[1].to_string()),
503        },
504        3 => Domain {
505            organization: Some(parts[0].to_string()),
506            project: Some(parts[1].to_string()),
507            repository: Some(parts[2].to_string()),
508        },
509        _ => Domain::new(),
510    }
511}
512
513/// Parses a status string into a `MemoryStatus`.
514fn parse_status_string(s: &str) -> MemoryStatus {
515    match s.to_lowercase().as_str() {
516        "archived" => MemoryStatus::Archived,
517        "superseded" => MemoryStatus::Superseded,
518        "pending" => MemoryStatus::Pending,
519        "deleted" => MemoryStatus::Deleted,
520        // Default to Active for "active" and any unknown status
521        _ => MemoryStatus::Active,
522    }
523}