Skip to main content

subcog/storage/index/
domain.rs

1//! Domain-scoped index management.
2//!
3//! Manages separate indices for different domain scopes:
4//! - **Project**: `<user-data>/index.db` - project memories with project/branch/path facets
5//! - **User**: `<user-data>/index.db` - user-wide memories
6//! - **Org**: Configured path or database URL - team/enterprise memories
7
8use crate::storage::index::SqliteBackend;
9use crate::{Error, Result};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14/// Domain scope for index isolation.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum DomainScope {
17    /// Project scope stored in user-level index with project faceting.
18    Project,
19    /// User-level index stored in the user data directory.
20    User,
21    /// Organization-level index, configured externally.
22    Org,
23}
24
25impl DomainScope {
26    /// Returns the scope as a string.
27    #[must_use]
28    pub const fn as_str(&self) -> &'static str {
29        match self {
30            Self::Project => "project",
31            Self::User => "user",
32            Self::Org => "org",
33        }
34    }
35
36    /// Returns the appropriate default domain scope based on current context.
37    ///
38    /// - If in a git repository (`.git` folder exists): returns `Project`
39    /// - If NOT in a git repository: returns `User`
40    ///
41    /// Storage for both scopes is user-level `SQLite` with facets for project/branch/path.
42    #[must_use]
43    pub fn default_for_context() -> Self {
44        if is_in_git_repo() {
45            Self::Project
46        } else {
47            Self::User
48        }
49    }
50
51    /// Returns the appropriate default domain scope for a specific path.
52    ///
53    /// - If path is in a git repository: returns `Project`
54    /// - If path is NOT in a git repository: returns `User`
55    #[must_use]
56    pub fn default_for_path(path: &Path) -> Self {
57        if is_path_in_git_repo(path) {
58            Self::Project
59        } else {
60            Self::User
61        }
62    }
63}
64
65/// Configuration for domain-scoped indices.
66#[derive(Debug, Clone, Default)]
67pub struct DomainIndexConfig {
68    /// Path to the git repository root (for project context/faceting).
69    pub repo_path: Option<PathBuf>,
70    /// Organization index configuration.
71    pub org_config: Option<OrgIndexConfig>,
72    /// User data directory (from config). If `None`, uses platform default.
73    ///
74    /// This should be set from `SubcogConfig.data_dir` to ensure all components
75    /// use the same path, respecting user configuration.
76    pub user_data_dir: Option<PathBuf>,
77}
78
79/// Organization index configuration.
80#[derive(Debug, Clone)]
81pub enum OrgIndexConfig {
82    /// `SQLite` file at a shared path.
83    SqlitePath(PathBuf),
84    /// PostgreSQL connection URL (future).
85    PostgresUrl(String),
86    /// Redis connection URL (future).
87    RedisUrl(String),
88}
89
90/// Manages indices for different domain scopes.
91pub struct DomainIndexManager {
92    /// Indices by domain scope.
93    indices: HashMap<DomainScope, Mutex<SqliteBackend>>,
94    /// Configuration.
95    config: DomainIndexConfig,
96    /// User data directory base path.
97    user_data_dir: PathBuf,
98}
99
100impl DomainIndexManager {
101    /// Creates a new domain index manager.
102    ///
103    /// # Arguments
104    ///
105    /// * `config` - Domain index configuration
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if required paths cannot be determined.
110    pub fn new(config: DomainIndexConfig) -> Result<Self> {
111        // Use config's user_data_dir if provided, otherwise fall back to platform default.
112        // This ensures all components use the same path, respecting user configuration.
113        let user_data_dir = config
114            .user_data_dir
115            .clone()
116            .map_or_else(get_user_data_dir, Ok)?;
117
118        Ok(Self {
119            indices: HashMap::new(),
120            config,
121            user_data_dir,
122        })
123    }
124
125    /// Gets or creates the index for a domain scope.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the index cannot be initialized.
130    pub fn get_or_create(&mut self, scope: DomainScope) -> Result<&Mutex<SqliteBackend>> {
131        if !self.indices.contains_key(&scope) {
132            let backend = self.create_index(scope)?;
133            self.indices.insert(scope, Mutex::new(backend));
134        }
135
136        self.indices
137            .get(&scope)
138            .ok_or_else(|| Error::OperationFailed {
139                operation: "get_index".to_string(),
140                cause: format!("Index for scope {scope:?} not found"),
141            })
142    }
143
144    /// Creates an index for the specified scope.
145    fn create_index(&self, scope: DomainScope) -> Result<SqliteBackend> {
146        let db_path = self.get_index_path(scope)?;
147
148        // Ensure parent directory exists
149        if let Some(parent) = db_path.parent() {
150            std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
151                operation: "create_index_dir".to_string(),
152                cause: e.to_string(),
153            })?;
154        }
155
156        SqliteBackend::new(&db_path)
157    }
158
159    /// Gets the index path for a domain scope.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the path cannot be determined.
164    pub fn get_index_path(&self, scope: DomainScope) -> Result<PathBuf> {
165        match scope {
166            DomainScope::Project => Ok(self.get_project_index_path()),
167            DomainScope::User => Ok(self.get_user_index_path()),
168            DomainScope::Org => self.get_org_index_path(),
169        }
170    }
171
172    /// Gets the project-scoped index path: `<user-data>/index.db`
173    fn get_project_index_path(&self) -> PathBuf {
174        self.user_data_dir.join("index.db")
175    }
176
177    /// Gets the user-scoped index path.
178    ///
179    /// `<user_data>/index.db`
180    fn get_user_index_path(&self) -> PathBuf {
181        self.user_data_dir.join("index.db")
182    }
183
184    /// Gets the org-scoped index path from configuration.
185    fn get_org_index_path(&self) -> Result<PathBuf> {
186        match &self.config.org_config {
187            Some(OrgIndexConfig::SqlitePath(path)) => Ok(path.clone()),
188            Some(OrgIndexConfig::PostgresUrl(_) | OrgIndexConfig::RedisUrl(_)) => {
189                Err(Error::NotImplemented(
190                    "PostgreSQL and Redis org indices not yet implemented".to_string(),
191                ))
192            },
193            None => {
194                // Default to user data dir org folder
195                Ok(self.user_data_dir.join("org").join("index.db"))
196            },
197        }
198    }
199
200    /// Returns all available scopes that have been initialized.
201    #[must_use]
202    pub fn available_scopes(&self) -> Vec<DomainScope> {
203        self.indices.keys().copied().collect()
204    }
205
206    /// Checks if a scope has an initialized index.
207    #[must_use]
208    pub fn has_scope(&self, scope: DomainScope) -> bool {
209        self.indices.contains_key(&scope)
210    }
211
212    /// Creates a new `SQLite` backend for the specified scope.
213    ///
214    /// This is a high-level method that:
215    /// 1. Determines the correct index path for the scope
216    /// 2. Ensures the parent directory exists
217    /// 3. Creates and returns a new `SqliteBackend`
218    ///
219    /// Unlike `get_or_create`, this always creates a fresh backend instance
220    /// rather than returning a cached one. This is useful when the caller
221    /// needs to manage the backend's lifecycle independently.
222    ///
223    /// # Arguments
224    ///
225    /// * `scope` - The domain scope for the index
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if:
230    /// - The index path cannot be determined (e.g., missing repo path for project scope)
231    /// - The parent directory cannot be created
232    /// - The `SQLite` backend fails to initialize
233    ///
234    /// # Examples
235    ///
236    /// ```rust,ignore
237    /// let manager = DomainIndexManager::new(config)?;
238    /// let backend = manager.create_backend(DomainScope::Project)?;
239    /// // backend is ready for use
240    /// ```
241    pub fn create_backend(&self, scope: DomainScope) -> Result<SqliteBackend> {
242        self.create_index(scope)
243    }
244
245    /// Creates a new `SQLite` backend with full path resolution.
246    ///
247    /// Returns both the created backend and the path it was created at.
248    /// This is useful when the caller needs to know the path for logging
249    /// or other purposes.
250    ///
251    /// # Arguments
252    ///
253    /// * `scope` - The domain scope for the index
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the backend cannot be created.
258    pub fn create_backend_with_path(&self, scope: DomainScope) -> Result<(SqliteBackend, PathBuf)> {
259        let path = self.get_index_path(scope)?;
260
261        // Ensure parent directory exists
262        if let Some(parent) = path.parent() {
263            std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
264                operation: "create_index_dir".to_string(),
265                cause: e.to_string(),
266            })?;
267        }
268
269        let backend = SqliteBackend::new(&path)?;
270        Ok((backend, path))
271    }
272}
273
274/// Gets the user data directory for subcog.
275///
276/// Returns the platform-specific user data directory:
277/// - macOS: `~/Library/Application Support/subcog/`
278/// - Linux: `~/.local/share/subcog/`
279/// - Windows: `C:\Users\<User>\AppData\Local\subcog\`
280///
281/// # Errors
282///
283/// Returns an error if the user data directory cannot be determined.
284pub fn get_user_data_dir() -> Result<PathBuf> {
285    directories::BaseDirs::new()
286        .map(|b| b.data_local_dir().join("subcog"))
287        .ok_or_else(|| Error::OperationFailed {
288            operation: "get_user_data_dir".to_string(),
289            cause: "Could not determine user data directory".to_string(),
290        })
291}
292
293/// Checks if the current working directory is inside a git repository.
294///
295/// Returns `true` if a `.git` directory exists in the current directory
296/// or any parent directory.
297#[must_use]
298pub fn is_in_git_repo() -> bool {
299    std::env::current_dir()
300        .map(|cwd| is_path_in_git_repo(&cwd))
301        .unwrap_or(false)
302}
303
304/// Checks if a given path is inside a git repository.
305///
306/// Returns `true` if a `.git` directory exists at or above the given path.
307#[must_use]
308pub fn is_path_in_git_repo(path: &Path) -> bool {
309    find_repo_root(path).is_ok()
310}
311
312/// Resolves the repository root from a given path.
313///
314/// Walks up the directory tree looking for a `.git` directory.
315///
316/// # Errors
317///
318/// Returns an error if no git repository is found.
319pub fn find_repo_root(start: &Path) -> Result<PathBuf> {
320    let mut current = start.to_path_buf();
321
322    // Canonicalize to resolve symlinks
323    if let Ok(canonical) = current.canonicalize() {
324        current = canonical;
325    }
326
327    loop {
328        if current.join(".git").exists() {
329            return Ok(current);
330        }
331
332        if !current.pop() {
333            break;
334        }
335    }
336
337    Err(Error::InvalidInput(format!(
338        "No git repository found starting from: {}",
339        start.display()
340    )))
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use tempfile::TempDir;
347
348    #[test]
349    fn test_domain_scope_as_str() {
350        assert_eq!(DomainScope::Project.as_str(), "project");
351        assert_eq!(DomainScope::User.as_str(), "user");
352        assert_eq!(DomainScope::Org.as_str(), "org");
353    }
354
355    #[test]
356    fn test_find_repo_root() {
357        let dir = TempDir::new().unwrap();
358        let repo_root = dir.path();
359
360        // Create .git directory
361        std::fs::create_dir(repo_root.join(".git")).unwrap();
362
363        // Create nested directory
364        let nested = repo_root.join("src").join("lib");
365        std::fs::create_dir_all(&nested).unwrap();
366
367        // Should find repo root from nested path
368        let found = find_repo_root(&nested).unwrap();
369        assert_eq!(found, repo_root.canonicalize().unwrap());
370    }
371
372    #[test]
373    fn test_find_repo_root_not_found() {
374        let dir = TempDir::new().unwrap();
375        // No .git directory
376        let result = find_repo_root(dir.path());
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn test_project_index_path() {
382        let config = DomainIndexConfig {
383            repo_path: Some(PathBuf::from("/path/to/repo")),
384            org_config: None,
385            user_data_dir: None,
386        };
387        let manager = DomainIndexManager::new(config).unwrap();
388
389        let path = manager.get_index_path(DomainScope::Project).unwrap();
390        let expected = get_user_data_dir().unwrap().join("index.db");
391        assert_eq!(path, expected);
392    }
393
394    #[test]
395    fn test_user_index_path() {
396        let config = DomainIndexConfig {
397            repo_path: Some(PathBuf::from("/path/to/repo")),
398            org_config: None,
399            user_data_dir: None,
400        };
401        let manager = DomainIndexManager::new(config).unwrap();
402
403        let path = manager.get_index_path(DomainScope::User).unwrap();
404        let expected = get_user_data_dir().unwrap().join("index.db");
405        assert_eq!(path, expected);
406    }
407
408    #[test]
409    fn test_org_index_path_configured() {
410        let config = DomainIndexConfig {
411            repo_path: Some(PathBuf::from("/path/to/repo")),
412            org_config: Some(OrgIndexConfig::SqlitePath(PathBuf::from(
413                "/shared/org/index.db",
414            ))),
415            user_data_dir: None,
416        };
417        let manager = DomainIndexManager::new(config).unwrap();
418
419        let path = manager.get_index_path(DomainScope::Org).unwrap();
420        assert_eq!(path, PathBuf::from("/shared/org/index.db"));
421    }
422
423    #[test]
424    fn test_is_path_in_git_repo_with_git() {
425        let dir = TempDir::new().unwrap();
426        let repo_root = dir.path();
427
428        // Create .git directory
429        std::fs::create_dir(repo_root.join(".git")).unwrap();
430
431        // Path with .git should return true
432        assert!(is_path_in_git_repo(repo_root));
433
434        // Nested path should also return true
435        let nested = repo_root.join("src");
436        std::fs::create_dir_all(&nested).unwrap();
437        assert!(is_path_in_git_repo(&nested));
438    }
439
440    #[test]
441    fn test_is_path_in_git_repo_without_git() {
442        let dir = TempDir::new().unwrap();
443        // No .git directory - should return false
444        assert!(!is_path_in_git_repo(dir.path()));
445    }
446
447    #[test]
448    fn test_default_for_path_in_git_repo() {
449        let dir = TempDir::new().unwrap();
450        let repo_root = dir.path();
451
452        // Create .git directory
453        std::fs::create_dir(repo_root.join(".git")).unwrap();
454
455        // Should default to Project when in git repo
456        assert_eq!(
457            DomainScope::default_for_path(repo_root),
458            DomainScope::Project
459        );
460    }
461
462    #[test]
463    fn test_default_for_path_not_in_git_repo() {
464        let dir = TempDir::new().unwrap();
465        // No .git directory - should default to User
466        assert_eq!(DomainScope::default_for_path(dir.path()), DomainScope::User);
467    }
468}