subcog/storage/prompt/mod.rs
1//! Prompt storage backends.
2//!
3//! Provides domain-scoped storage for prompt templates with pluggable backends:
4//!
5//! - **Project scope**: `SQLite` (faceted by repo/branch)
6//! - **User scope**: `SQLite`, PostgreSQL, Redis, or Filesystem
7//! - **Org scope**: `SQLite` or Filesystem (org-isolated)
8//!
9//! # URN Scheme
10//!
11//! `subcog://{domain}/_prompts/{prompt-name}`
12//!
13//! Examples:
14//! - `subcog://project/_prompts/code-review`
15//! - `subcog://user/_prompts/api-design`
16//! - `subcog://org/_prompts/team-review`
17//!
18//! # Backend Selection Logic
19//!
20//! The backend is selected based on configuration and domain:
21//!
22//! ```text
23//! 1. Check explicit backend type in config/env
24//! ├─► PostgreSQL if SUBCOG_POSTGRES_URL set + domain supports it
25//! ├─► Redis if SUBCOG_REDIS_URL set + domain supports it
26//! └─► Continue to step 2
27//!
28//! 2. Check domain scope
29//! ├─► Project → `SQLite` (faceted by repo/branch)
30//! ├─► User → `SQLite` (local, performant, no server required)
31//! └─► Org → `SQLite` with org-prefixed path
32//!
33//! 3. Fallback
34//! └─► Filesystem (always available, human-readable YAML files)
35//! ```
36//!
37//! ## Selection Priority by Domain
38//!
39//! | Domain | Priority Order | Rationale |
40//! |--------|----------------|-----------|
41//! | Project | `SQLite` → Filesystem | Faceted by repo/branch |
42//! | User | `PostgreSQL` → `Redis` → `SQLite` → Filesystem | Configured external, then local |
43//! | Org | `PostgreSQL` → `Redis` → `SQLite` → Filesystem | Shared org database preferred |
44//!
45//! ## Backend Capabilities
46//!
47//! | Backend | ACID | Shared | Versioned | Query | Setup |
48//! |---------|------|--------|-----------|-------|-------|
49//! | `SQLite` | Yes | No | No | Full SQL | None |
50//! | PostgreSQL | Yes | Yes | No | Full SQL | Server |
51//! | Redis | No | Yes | No | Pattern | Server |
52//! | Filesystem | No | Via sync | No | Glob only | None |
53//!
54//! # Domain Routing
55//!
56//! Each domain scope maps to an appropriate storage backend:
57//!
58//! | Domain | Backend | Location |
59//! |--------|---------|----------|
60//! | Project | `SQLite` | `~/.config/subcog/memories.db` (with repo/branch facets) |
61//! | User | `SQLite` | `~/.config/subcog/memories.db` |
62//! | User | PostgreSQL | Configured connection |
63//! | User | Redis | Configured connection |
64//! | User | Filesystem | `~/.config/subcog/prompts/` |
65//! | Org | `SQLite` | `~/.config/subcog/orgs/{org}/memories.db` |
66//! | Org | Filesystem | `~/.config/subcog/orgs/{org}/prompts/` |
67//!
68//! # Org Identifier Resolution
69//!
70//! The org identifier is resolved from:
71//! 1. `SUBCOG_ORG` environment variable (highest priority)
72//! 2. Git remote URL (extracts org from `github.com/org/repo`)
73//! 3. Returns error if no identifier can be resolved
74
75mod filesystem;
76mod postgresql;
77mod redis;
78mod sqlite;
79mod traits;
80
81pub use filesystem::FilesystemPromptStorage;
82pub use postgresql::PostgresPromptStorage;
83pub use redis::RedisPromptStorage;
84pub use sqlite::SqlitePromptStorage;
85pub use traits::PromptStorage;
86
87use crate::config::Config;
88use crate::storage::index::DomainScope;
89use crate::{Error, Result};
90use std::path::PathBuf;
91use std::sync::Arc;
92
93/// Backend type for prompt storage.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum PromptBackendType {
96 /// `SQLite` database (default, authoritative storage).
97 #[default]
98 Sqlite,
99 /// PostgreSQL database.
100 PostgreSQL,
101 /// Redis with `RediSearch`.
102 Redis,
103 /// Filesystem fallback.
104 Filesystem,
105}
106
107/// Factory for creating domain-scoped prompt storage.
108pub struct PromptStorageFactory;
109
110impl PromptStorageFactory {
111 /// Creates a prompt storage for the given domain scope.
112 ///
113 /// # Domain Routing
114 ///
115 /// - **Project**: `SQLite` at `~/.config/subcog/memories.db` (with repo/branch facets)
116 /// - **User**: `SQLite` at `~/.config/subcog/memories.db` (default)
117 /// - **Org**: `SQLite` at `~/.config/subcog/orgs/{org}/memories.db`
118 ///
119 /// # Arguments
120 ///
121 /// * `scope` - The domain scope
122 /// * `config` - Application configuration
123 ///
124 /// # Errors
125 ///
126 /// Returns an error if:
127 /// - Org scope is requested but no org identifier can be resolved
128 /// - Storage backend cannot be initialized
129 pub fn create_for_scope(scope: DomainScope, config: &Config) -> Result<Arc<dyn PromptStorage>> {
130 match scope {
131 DomainScope::Project => Self::create_project_storage(config),
132 DomainScope::User => Self::create_user_storage(config),
133 DomainScope::Org => Self::create_org_storage(config),
134 }
135 }
136
137 /// Creates storage for the given scope using full subcog configuration.
138 ///
139 /// Uses the storage settings from the config file when available.
140 ///
141 /// # Errors
142 ///
143 /// Returns an error if:
144 /// - The storage backend cannot be initialized
145 /// - Required paths or connection strings are missing
146 /// - The Redis feature is not enabled for Redis backend
147 pub fn create_for_scope_with_subcog_config(
148 scope: DomainScope,
149 config: &crate::config::SubcogConfig,
150 ) -> Result<Arc<dyn PromptStorage>> {
151 use crate::config::StorageBackendType;
152
153 if matches!(scope, DomainScope::Org)
154 && !(config.features.org_scope_enabled || cfg!(feature = "org-scope"))
155 {
156 return Err(Error::FeatureNotEnabled("org-scope".to_string()));
157 }
158
159 let storage_config = match scope {
160 DomainScope::Project => &config.storage.project,
161 DomainScope::User => &config.storage.user,
162 DomainScope::Org => &config.storage.org,
163 };
164
165 let backend = match storage_config.backend {
166 StorageBackendType::Sqlite => PromptBackendType::Sqlite,
167 StorageBackendType::Filesystem => PromptBackendType::Filesystem,
168 StorageBackendType::PostgreSQL => PromptBackendType::PostgreSQL,
169 StorageBackendType::Redis => PromptBackendType::Redis,
170 };
171
172 let path = storage_config.path.as_ref().map(PathBuf::from);
173 let connection_url = storage_config.connection_string.clone();
174
175 Self::create_with_backend(backend, path, connection_url)
176 }
177
178 /// Creates project-scoped storage (`SQLite` in project directory).
179 fn create_project_storage(_config: &Config) -> Result<Arc<dyn PromptStorage>> {
180 // Project scope now uses SQLite (same as user scope for consistency)
181 if let Some(db_path) = SqlitePromptStorage::default_user_path() {
182 match SqlitePromptStorage::new(&db_path) {
183 Ok(storage) => return Ok(Arc::new(storage)),
184 Err(e) => {
185 tracing::warn!("Failed to create SQLite prompt storage: {e}");
186 },
187 }
188 }
189
190 // Fallback to filesystem
191 let fs_path =
192 FilesystemPromptStorage::default_user_path().ok_or_else(|| Error::OperationFailed {
193 operation: "create_project_storage".to_string(),
194 cause: "Could not determine user config directory".to_string(),
195 })?;
196
197 Ok(Arc::new(FilesystemPromptStorage::new(fs_path)?))
198 }
199
200 /// Creates user-scoped storage based on configuration.
201 ///
202 /// Priority order:
203 /// 1. `SQLite` (default)
204 /// 2. Filesystem (fallback if `SQLite` fails)
205 ///
206 /// Note: PostgreSQL and Redis support requires explicit backend selection
207 /// via `create_with_backend()`.
208 fn create_user_storage(_config: &Config) -> Result<Arc<dyn PromptStorage>> {
209 // Try SQLite (default)
210 if let Some(db_path) = SqlitePromptStorage::default_user_path() {
211 match SqlitePromptStorage::new(&db_path) {
212 Ok(storage) => return Ok(Arc::new(storage)),
213 Err(e) => {
214 tracing::warn!("Failed to create SQLite prompt storage: {e}");
215 // Fall through to filesystem
216 },
217 }
218 }
219
220 // Fallback to filesystem
221 let fs_path =
222 FilesystemPromptStorage::default_user_path().ok_or_else(|| Error::OperationFailed {
223 operation: "create_user_storage".to_string(),
224 cause: "Could not determine user config directory".to_string(),
225 })?;
226
227 Ok(Arc::new(FilesystemPromptStorage::new(fs_path)?))
228 }
229
230 /// Creates org-scoped storage based on configuration.
231 ///
232 /// Resolves org identifier from:
233 /// 1. `SUBCOG_ORG` environment variable
234 /// 2. Git remote URL (extracts org from `github.com/org/repo`)
235 /// 3. Falls back with error if no org can be resolved
236 ///
237 /// Priority order:
238 /// 1. `SQLite` (default)
239 /// 2. Filesystem (fallback if `SQLite` fails)
240 fn create_org_storage(config: &Config) -> Result<Arc<dyn PromptStorage>> {
241 let org = Self::resolve_org_identifier(config)?;
242
243 // Try SQLite (default)
244 if let Some(db_path) = SqlitePromptStorage::default_org_path(&org) {
245 match SqlitePromptStorage::new(&db_path) {
246 Ok(storage) => return Ok(Arc::new(storage)),
247 Err(e) => {
248 tracing::warn!("Failed to create SQLite org prompt storage: {e}");
249 // Fall through to filesystem
250 },
251 }
252 }
253
254 // Fallback to filesystem
255 let fs_path = FilesystemPromptStorage::default_org_path(&org).ok_or_else(|| {
256 Error::OperationFailed {
257 operation: "create_org_storage".to_string(),
258 cause: "Could not determine org config directory".to_string(),
259 }
260 })?;
261
262 Ok(Arc::new(FilesystemPromptStorage::new(fs_path)?))
263 }
264
265 /// Resolves the organization identifier.
266 ///
267 /// Priority:
268 /// 1. `SUBCOG_ORG` environment variable
269 /// 2. Git remote URL (extracts org from `github.com/org/repo`)
270 fn resolve_org_identifier(config: &Config) -> Result<String> {
271 // 1. Check SUBCOG_ORG environment variable
272 if let Ok(org) = std::env::var("SUBCOG_ORG")
273 && !org.is_empty()
274 {
275 return Ok(org);
276 }
277
278 // 2. Try to extract from git remote in config repo path
279 if let Some(ref repo_path) = config.repo_path
280 && let Some(org) = Self::extract_org_from_repo_path(repo_path)
281 {
282 return Ok(org);
283 }
284
285 // 3. Try current directory as fallback
286 if let Ok(cwd) = std::env::current_dir()
287 && let Some(org) = Self::extract_org_from_repo_path(&cwd)
288 {
289 return Ok(org);
290 }
291
292 Err(Error::InvalidInput(
293 "Could not resolve organization identifier. \
294 Set SUBCOG_ORG environment variable or ensure git remote is configured."
295 .to_string(),
296 ))
297 }
298
299 /// Extracts organization from a git repository's origin remote.
300 fn extract_org_from_repo_path(path: &std::path::Path) -> Option<String> {
301 let repo = git2::Repository::open(path).ok()?;
302 let remote = repo.find_remote("origin").ok()?;
303 let url = remote.url()?;
304 Self::extract_org_from_git_url(url)
305 }
306
307 /// Extracts organization name from a git URL.
308 ///
309 /// Supports:
310 /// - `https://github.com/org/repo.git`
311 /// - `git@github.com:org/repo.git`
312 /// - `ssh://git@github.com/org/repo.git`
313 fn extract_org_from_git_url(url: &str) -> Option<String> {
314 // Handle SSH format: git@github.com:org/repo.git
315 if let Some((rest, path_start)) = url
316 .strip_prefix("git@")
317 .and_then(|rest| rest.find(':').map(|path_start| (rest, path_start)))
318 {
319 let path = &rest[path_start + 1..];
320 return path.split('/').next().map(ToString::to_string);
321 }
322
323 // Handle HTTPS/SSH URL format
324 // Examples:
325 // - https://github.com/org/repo.git
326 // - ssh://git@github.com/org/repo.git
327 let path = url
328 .strip_prefix("https://")
329 .or_else(|| url.strip_prefix("http://"))
330 .or_else(|| url.strip_prefix("ssh://"))
331 .or_else(|| url.strip_prefix("git://"));
332
333 if let Some(org) = path.and_then(|rest| rest.split('/').nth(1)) {
334 // parts[0] = host (github.com) or user@host
335 // parts[1] = org
336 return Some(org.to_string());
337 }
338
339 None
340 }
341
342 /// Creates storage with an explicit backend type.
343 ///
344 /// # Arguments
345 ///
346 /// * `backend` - The backend type to use
347 /// * `path` - Path for file-based backends (repo path for git, db path for `SQLite`, dir for filesystem)
348 /// * `connection_url` - Connection URL for network backends (PostgreSQL, Redis)
349 ///
350 /// # Errors
351 ///
352 /// Returns an error if the backend cannot be initialized.
353 pub fn create_with_backend(
354 backend: PromptBackendType,
355 path: Option<PathBuf>,
356 connection_url: Option<String>,
357 ) -> Result<Arc<dyn PromptStorage>> {
358 match backend {
359 PromptBackendType::Sqlite => {
360 let db_path = path
361 .or_else(SqlitePromptStorage::default_user_path)
362 .ok_or_else(|| {
363 Error::InvalidInput("Database path required for SQLite backend".to_string())
364 })?;
365 Ok(Arc::new(SqlitePromptStorage::new(db_path)?))
366 },
367 PromptBackendType::PostgreSQL => {
368 let url = connection_url.ok_or_else(|| {
369 Error::InvalidInput(
370 "Connection URL required for PostgreSQL backend".to_string(),
371 )
372 })?;
373 Ok(Arc::new(PostgresPromptStorage::new(&url, "prompts")?))
374 },
375 PromptBackendType::Redis => {
376 let url = connection_url.ok_or_else(|| {
377 Error::InvalidInput("Connection URL required for Redis backend".to_string())
378 })?;
379 #[cfg(feature = "redis")]
380 {
381 Ok(Arc::new(RedisPromptStorage::new(&url, "subcog_prompts")?))
382 }
383 #[cfg(not(feature = "redis"))]
384 {
385 let _ = url;
386 Err(Error::FeatureNotEnabled("redis".to_string()))
387 }
388 },
389 PromptBackendType::Filesystem => {
390 let dir_path = path
391 .or_else(FilesystemPromptStorage::default_user_path)
392 .ok_or_else(|| {
393 Error::InvalidInput(
394 "Directory path required for filesystem backend".to_string(),
395 )
396 })?;
397 Ok(Arc::new(FilesystemPromptStorage::new(dir_path)?))
398 },
399 }
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use tempfile::TempDir;
407
408 #[test]
409 fn test_prompt_backend_type_default() {
410 let default = PromptBackendType::default();
411 assert_eq!(default, PromptBackendType::Sqlite);
412 }
413
414 #[test]
415 fn test_create_with_sqlite_backend() {
416 let dir = TempDir::new().unwrap();
417 let db_path = dir.path().join("prompts.db");
418
419 let storage = PromptStorageFactory::create_with_backend(
420 PromptBackendType::Sqlite,
421 Some(db_path),
422 None,
423 );
424
425 assert!(storage.is_ok());
426 }
427
428 #[test]
429 fn test_create_with_filesystem_backend() {
430 let dir = TempDir::new().unwrap();
431
432 let storage = PromptStorageFactory::create_with_backend(
433 PromptBackendType::Filesystem,
434 Some(dir.path().to_path_buf()),
435 None,
436 );
437
438 assert!(storage.is_ok());
439 }
440
441 #[test]
442 fn test_create_org_scope_with_git_remote() {
443 // This test verifies that org scope works when in a git repo with origin remote.
444 // If SUBCOG_ORG is set or we're in a repo with origin, this should succeed.
445 let config = Config::default();
446 let result = PromptStorageFactory::create_for_scope(DomainScope::Org, &config);
447
448 // In most test environments (including CI), we're in a git repo with origin.
449 // The result depends on whether an org identifier can be resolved.
450 // If SUBCOG_ORG is set OR we have a git origin, it should succeed.
451 // If neither, it fails with InvalidInput.
452 if result.is_ok() {
453 // Org was resolved from env or git remote - success path
454 assert!(result.is_ok());
455 } else {
456 // No org could be resolved - error path
457 let Err(err) = result else { return };
458 assert!(
459 matches!(err, Error::InvalidInput(ref msg) if msg.contains("organization identifier")),
460 "Expected InvalidInput error about org identifier"
461 );
462 }
463 }
464
465 #[test]
466 fn test_resolve_org_from_git_url_in_config() {
467 // Test org resolution with a repo path pointing to this repo
468 let cwd = std::env::current_dir().ok();
469 let config = Config {
470 repo_path: cwd,
471 ..Config::default()
472 };
473
474 // If we're in a git repo with origin, this should succeed
475 let result = PromptStorageFactory::resolve_org_identifier(&config);
476
477 // Result depends on environment - in most cases we're in a git repo
478 if let Ok(org) = result {
479 assert!(!org.is_empty(), "Org should not be empty");
480 }
481 }
482
483 #[test]
484 fn test_extract_org_from_git_url() {
485 // HTTPS format
486 assert_eq!(
487 PromptStorageFactory::extract_org_from_git_url("https://github.com/zircote/subcog.git"),
488 Some("zircote".to_string())
489 );
490
491 // SSH format
492 assert_eq!(
493 PromptStorageFactory::extract_org_from_git_url("git@github.com:zircote/subcog.git"),
494 Some("zircote".to_string())
495 );
496
497 // SSH URL format
498 assert_eq!(
499 PromptStorageFactory::extract_org_from_git_url(
500 "ssh://git@github.com/zircote/subcog.git"
501 ),
502 Some("zircote".to_string())
503 );
504
505 // GitLab
506 assert_eq!(
507 PromptStorageFactory::extract_org_from_git_url("https://gitlab.com/myorg/myrepo.git"),
508 Some("myorg".to_string())
509 );
510
511 // Invalid URL
512 assert_eq!(
513 PromptStorageFactory::extract_org_from_git_url("not-a-url"),
514 None
515 );
516 }
517
518 #[cfg(not(feature = "postgres"))]
519 #[test]
520 fn test_create_postgresql_backend_stub() {
521 let storage = PromptStorageFactory::create_with_backend(
522 PromptBackendType::PostgreSQL,
523 None,
524 Some("postgresql://localhost/test".to_string()),
525 );
526
527 // Should create successfully (stub returns Ok)
528 assert!(storage.is_ok());
529 }
530
531 #[cfg(not(feature = "redis"))]
532 #[test]
533 fn test_create_redis_backend_without_feature() {
534 let result = PromptStorageFactory::create_with_backend(
535 PromptBackendType::Redis,
536 None,
537 Some("redis://localhost".to_string()),
538 );
539
540 assert!(matches!(result, Err(Error::FeatureNotEnabled(_))));
541 }
542}