subcog/storage/index/
domain.rs1use crate::storage::index::SqliteBackend;
9use crate::{Error, Result};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum DomainScope {
17 Project,
19 User,
21 Org,
23}
24
25impl DomainScope {
26 #[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#[derive(Debug, Clone, Default)]
39pub struct DomainIndexConfig {
40 pub repo_path: Option<PathBuf>,
42 pub org_config: Option<OrgIndexConfig>,
44}
45
46#[derive(Debug, Clone)]
48pub enum OrgIndexConfig {
49 SqlitePath(PathBuf),
51 PostgresUrl(String),
53 RedisUrl(String),
55}
56
57pub struct DomainIndexManager {
59 indices: HashMap<DomainScope, Mutex<SqliteBackend>>,
61 config: DomainIndexConfig,
63 user_data_dir: PathBuf,
65}
66
67impl DomainIndexManager {
68 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 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 fn create_index(&self, scope: DomainScope) -> Result<SqliteBackend> {
108 let db_path = self.get_index_path(scope)?;
109
110 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 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 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 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 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 Ok(self.user_data_dir.join("org").join("index.db"))
165 },
166 }
167 }
168
169 #[must_use]
171 pub fn available_scopes(&self) -> Vec<DomainScope> {
172 self.indices.keys().copied().collect()
173 }
174
175 #[must_use]
177 pub fn has_scope(&self, scope: DomainScope) -> bool {
178 self.indices.contains_key(&scope)
179 }
180}
181
182fn 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
192fn 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 format!("{hash:016x}")
207}
208
209pub fn find_repo_root(start: &Path) -> Result<PathBuf> {
217 let mut current = start.to_path_buf();
218
219 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 std::fs::create_dir(repo_root.join(".git")).unwrap();
276
277 let nested = repo_root.join("src").join("lib");
279 std::fs::create_dir_all(&nested).unwrap();
280
281 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 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}