subcog/storage/index/
domain.rs

1//! Domain-scoped index management.
2//!
3//! Manages separate indices for different domain scopes:
4//! - **Project**: `<repo>/.subcog/index.db` - project-specific memories
5//! - **User**: `~/Library/.../subcog/repos/<hash>/index.db` - personal memories per repo
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-local index in `.subcog/` within the repository.
18    Project,
19    /// User-level index, hashed by repository path.
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
37/// Configuration for domain-scoped indices.
38#[derive(Debug, Clone, Default)]
39pub struct DomainIndexConfig {
40    /// Path to the git repository root (for project scope).
41    pub repo_path: Option<PathBuf>,
42    /// Organization index configuration.
43    pub org_config: Option<OrgIndexConfig>,
44}
45
46/// Organization index configuration.
47#[derive(Debug, Clone)]
48pub enum OrgIndexConfig {
49    /// `SQLite` file at a shared path.
50    SqlitePath(PathBuf),
51    /// PostgreSQL connection URL (future).
52    PostgresUrl(String),
53    /// Redis connection URL (future).
54    RedisUrl(String),
55}
56
57/// Manages indices for different domain scopes.
58pub struct DomainIndexManager {
59    /// Indices by domain scope.
60    indices: HashMap<DomainScope, Mutex<SqliteBackend>>,
61    /// Configuration.
62    config: DomainIndexConfig,
63    /// User data directory base path.
64    user_data_dir: PathBuf,
65}
66
67impl DomainIndexManager {
68    /// Creates a new domain index manager.
69    ///
70    /// # Arguments
71    ///
72    /// * `config` - Domain index configuration
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if required paths cannot be determined.
77    pub fn new(config: DomainIndexConfig) -> Result<Self> {
78        let user_data_dir = get_user_data_dir()?;
79
80        Ok(Self {
81            indices: HashMap::new(),
82            config,
83            user_data_dir,
84        })
85    }
86
87    /// Gets or creates the index for a domain scope.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the index cannot be initialized.
92    pub fn get_or_create(&mut self, scope: DomainScope) -> Result<&Mutex<SqliteBackend>> {
93        if !self.indices.contains_key(&scope) {
94            let backend = self.create_index(scope)?;
95            self.indices.insert(scope, Mutex::new(backend));
96        }
97
98        self.indices
99            .get(&scope)
100            .ok_or_else(|| Error::OperationFailed {
101                operation: "get_index".to_string(),
102                cause: format!("Index for scope {scope:?} not found"),
103            })
104    }
105
106    /// Creates an index for the specified scope.
107    fn create_index(&self, scope: DomainScope) -> Result<SqliteBackend> {
108        let db_path = self.get_index_path(scope)?;
109
110        // Ensure parent directory exists
111        if let Some(parent) = db_path.parent() {
112            std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
113                operation: "create_index_dir".to_string(),
114                cause: e.to_string(),
115            })?;
116        }
117
118        SqliteBackend::new(&db_path)
119    }
120
121    /// Gets the index path for a domain scope.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the path cannot be determined.
126    pub fn get_index_path(&self, scope: DomainScope) -> Result<PathBuf> {
127        match scope {
128            DomainScope::Project => self.get_project_index_path(),
129            DomainScope::User => self.get_user_index_path(),
130            DomainScope::Org => self.get_org_index_path(),
131        }
132    }
133
134    /// Gets the project-scoped index path: `<repo>/.subcog/index.db`
135    fn get_project_index_path(&self) -> Result<PathBuf> {
136        let repo_path = self.config.repo_path.as_ref().ok_or_else(|| {
137            Error::InvalidInput("Repository path not configured for project scope".to_string())
138        })?;
139
140        Ok(repo_path.join(".subcog").join("index.db"))
141    }
142
143    /// Gets the user-scoped index path: `<user_data>/repos/<hash>/index.db`
144    fn get_user_index_path(&self) -> Result<PathBuf> {
145        let repo_path = self.config.repo_path.as_ref().ok_or_else(|| {
146            Error::InvalidInput("Repository path not configured for user scope".to_string())
147        })?;
148
149        let hash = hash_path(repo_path);
150        Ok(self.user_data_dir.join("repos").join(hash).join("index.db"))
151    }
152
153    /// Gets the org-scoped index path from configuration.
154    fn get_org_index_path(&self) -> Result<PathBuf> {
155        match &self.config.org_config {
156            Some(OrgIndexConfig::SqlitePath(path)) => Ok(path.clone()),
157            Some(OrgIndexConfig::PostgresUrl(_) | OrgIndexConfig::RedisUrl(_)) => {
158                Err(Error::NotImplemented(
159                    "PostgreSQL and Redis org indices not yet implemented".to_string(),
160                ))
161            },
162            None => {
163                // Default to user data dir org folder
164                Ok(self.user_data_dir.join("org").join("index.db"))
165            },
166        }
167    }
168
169    /// Returns all available scopes that have been initialized.
170    #[must_use]
171    pub fn available_scopes(&self) -> Vec<DomainScope> {
172        self.indices.keys().copied().collect()
173    }
174
175    /// Checks if a scope has an initialized index.
176    #[must_use]
177    pub fn has_scope(&self, scope: DomainScope) -> bool {
178        self.indices.contains_key(&scope)
179    }
180}
181
182/// Gets the user data directory for subcog.
183fn get_user_data_dir() -> Result<PathBuf> {
184    directories::BaseDirs::new()
185        .map(|b| b.data_local_dir().join("subcog"))
186        .ok_or_else(|| Error::OperationFailed {
187            operation: "get_user_data_dir".to_string(),
188            cause: "Could not determine user data directory".to_string(),
189        })
190}
191
192/// Hashes a path for use as a directory name.
193///
194/// Uses a short hash to avoid overly long paths while maintaining uniqueness.
195fn hash_path(path: &Path) -> String {
196    use std::collections::hash_map::DefaultHasher;
197    use std::hash::{Hash, Hasher};
198
199    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
200
201    let mut hasher = DefaultHasher::new();
202    canonical.hash(&mut hasher);
203    let hash = hasher.finish();
204
205    // Use first 16 hex chars (64 bits) - collision-resistant enough
206    format!("{hash:016x}")
207}
208
209/// Resolves the repository root from a given path.
210///
211/// Walks up the directory tree looking for a `.git` directory.
212///
213/// # Errors
214///
215/// Returns an error if no git repository is found.
216pub fn find_repo_root(start: &Path) -> Result<PathBuf> {
217    let mut current = start.to_path_buf();
218
219    // Canonicalize to resolve symlinks
220    if let Ok(canonical) = current.canonicalize() {
221        current = canonical;
222    }
223
224    loop {
225        if current.join(".git").exists() {
226            return Ok(current);
227        }
228
229        if !current.pop() {
230            break;
231        }
232    }
233
234    Err(Error::InvalidInput(format!(
235        "No git repository found starting from: {}",
236        start.display()
237    )))
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use tempfile::TempDir;
244
245    #[test]
246    fn test_domain_scope_as_str() {
247        assert_eq!(DomainScope::Project.as_str(), "project");
248        assert_eq!(DomainScope::User.as_str(), "user");
249        assert_eq!(DomainScope::Org.as_str(), "org");
250    }
251
252    #[test]
253    fn test_hash_path_consistency() {
254        let path = PathBuf::from("/tmp/test/repo");
255        let hash1 = hash_path(&path);
256        let hash2 = hash_path(&path);
257        assert_eq!(hash1, hash2);
258    }
259
260    #[test]
261    fn test_hash_path_uniqueness() {
262        let path1 = PathBuf::from("/tmp/test/repo1");
263        let path2 = PathBuf::from("/tmp/test/repo2");
264        let hash1 = hash_path(&path1);
265        let hash2 = hash_path(&path2);
266        assert_ne!(hash1, hash2);
267    }
268
269    #[test]
270    fn test_find_repo_root() {
271        let dir = TempDir::new().unwrap();
272        let repo_root = dir.path();
273
274        // Create .git directory
275        std::fs::create_dir(repo_root.join(".git")).unwrap();
276
277        // Create nested directory
278        let nested = repo_root.join("src").join("lib");
279        std::fs::create_dir_all(&nested).unwrap();
280
281        // Should find repo root from nested path
282        let found = find_repo_root(&nested).unwrap();
283        assert_eq!(found, repo_root.canonicalize().unwrap());
284    }
285
286    #[test]
287    fn test_find_repo_root_not_found() {
288        let dir = TempDir::new().unwrap();
289        // No .git directory
290        let result = find_repo_root(dir.path());
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_project_index_path() {
296        let config = DomainIndexConfig {
297            repo_path: Some(PathBuf::from("/path/to/repo")),
298            org_config: None,
299        };
300        let manager = DomainIndexManager::new(config).unwrap();
301
302        let path = manager.get_index_path(DomainScope::Project).unwrap();
303        assert_eq!(path, PathBuf::from("/path/to/repo/.subcog/index.db"));
304    }
305
306    #[test]
307    fn test_user_index_path() {
308        let config = DomainIndexConfig {
309            repo_path: Some(PathBuf::from("/path/to/repo")),
310            org_config: None,
311        };
312        let manager = DomainIndexManager::new(config).unwrap();
313
314        let path = manager.get_index_path(DomainScope::User).unwrap();
315        assert!(path.to_string_lossy().contains("repos"));
316        assert!(path.to_string_lossy().ends_with("index.db"));
317    }
318
319    #[test]
320    fn test_org_index_path_configured() {
321        let config = DomainIndexConfig {
322            repo_path: Some(PathBuf::from("/path/to/repo")),
323            org_config: Some(OrgIndexConfig::SqlitePath(PathBuf::from(
324                "/shared/org/index.db",
325            ))),
326        };
327        let manager = DomainIndexManager::new(config).unwrap();
328
329        let path = manager.get_index_path(DomainScope::Org).unwrap();
330        assert_eq!(path, PathBuf::from("/shared/org/index.db"));
331    }
332}