Skip to main content

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}