Skip to main content

subcog/storage/context_template/
mod.rs

1//! Context template storage backends.
2//!
3//! Provides domain-scoped storage for context templates with versioning support.
4//! Each save auto-increments the version number, allowing retrieval of specific
5//! versions or the latest version.
6//!
7//! # URN Scheme
8//!
9//! `subcog://{domain}/_context_templates/{template-name}@{version}`
10//!
11//! Examples:
12//! - `subcog://project/_context_templates/search-results@1`
13//! - `subcog://user/_context_templates/memory-context@latest`
14//!
15//! # Backend Selection
16//!
17//! Currently only `SQLite` is supported. The backend stores templates in
18//! `~/.config/subcog/memories.db` alongside other subcog data.
19//!
20//! # Versioning
21//!
22//! - Each save creates a new version (auto-increment)
23//! - Retrieve specific version: `get("name", Some(2))`
24//! - Retrieve latest: `get("name", None)`
25//! - List available versions: `get_versions("name")`
26//! - Delete specific version or all versions
27
28mod sqlite;
29mod traits;
30
31pub use sqlite::{ContextTemplateDbStats, SqliteContextTemplateStorage};
32pub use traits::ContextTemplateStorage;
33
34use crate::config::SubcogConfig;
35use crate::storage::index::DomainScope;
36use crate::{Error, Result};
37use std::path::PathBuf;
38use std::sync::Arc;
39
40/// Backend type for context template storage.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum ContextTemplateBackendType {
43    /// `SQLite` database (default, authoritative storage).
44    #[default]
45    Sqlite,
46}
47
48/// Factory for creating domain-scoped context template storage.
49pub struct ContextTemplateStorageFactory;
50
51impl ContextTemplateStorageFactory {
52    /// Creates a context template storage for the given domain scope.
53    ///
54    /// # Domain Routing
55    ///
56    /// - **Project**: `SQLite` at `~/.config/subcog/memories.db` (with repo/branch facets)
57    /// - **User**: `SQLite` at `~/.config/subcog/memories.db` (default)
58    /// - **Org**: `SQLite` at `~/.config/subcog/orgs/{org}/memories.db`
59    ///
60    /// # Arguments
61    ///
62    /// * `scope` - The domain scope
63    /// * `config` - Application configuration
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if:
68    /// - Org scope is requested but no org identifier can be resolved
69    /// - Storage backend cannot be initialized
70    pub fn create_for_scope(
71        scope: DomainScope,
72        config: &SubcogConfig,
73    ) -> Result<Arc<dyn ContextTemplateStorage>> {
74        if matches!(scope, DomainScope::Org)
75            && !(config.features.org_scope_enabled || cfg!(feature = "org-scope"))
76        {
77            return Err(Error::FeatureNotEnabled("org-scope".to_string()));
78        }
79
80        let path = match scope {
81            DomainScope::Project | DomainScope::User => config
82                .storage
83                .user
84                .path
85                .as_ref()
86                .map(PathBuf::from)
87                .or_else(SqliteContextTemplateStorage::default_user_path),
88            DomainScope::Org => {
89                let org = Self::resolve_org_identifier()?;
90                config
91                    .storage
92                    .org
93                    .path
94                    .as_ref()
95                    .map(PathBuf::from)
96                    .or_else(|| SqliteContextTemplateStorage::default_org_path(&org))
97            },
98        };
99
100        let db_path = path.ok_or_else(|| Error::OperationFailed {
101            operation: "create_context_template_storage".to_string(),
102            cause: "Could not determine database path".to_string(),
103        })?;
104
105        Ok(Arc::new(SqliteContextTemplateStorage::new(db_path)?))
106    }
107
108    /// Creates storage with an explicit path.
109    ///
110    /// # Arguments
111    ///
112    /// * `path` - Path to the `SQLite` database file
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the database cannot be initialized.
117    pub fn create_with_path(path: PathBuf) -> Result<Arc<dyn ContextTemplateStorage>> {
118        Ok(Arc::new(SqliteContextTemplateStorage::new(path)?))
119    }
120
121    /// Creates an in-memory storage (useful for testing).
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the database cannot be initialized.
126    pub fn create_in_memory() -> Result<Arc<dyn ContextTemplateStorage>> {
127        Ok(Arc::new(SqliteContextTemplateStorage::in_memory()?))
128    }
129
130    /// Resolves the organization identifier.
131    ///
132    /// Priority:
133    /// 1. `SUBCOG_ORG` environment variable
134    /// 2. Git remote URL (extracts org from `github.com/org/repo`)
135    fn resolve_org_identifier() -> Result<String> {
136        // 1. Check SUBCOG_ORG environment variable
137        if let Ok(org) = std::env::var("SUBCOG_ORG")
138            && !org.is_empty()
139        {
140            return Ok(org);
141        }
142
143        // 2. Try current directory
144        if let Ok(cwd) = std::env::current_dir()
145            && let Some(org) = Self::extract_org_from_repo_path(&cwd)
146        {
147            return Ok(org);
148        }
149
150        Err(Error::InvalidInput(
151            "Could not resolve organization identifier. \
152             Set SUBCOG_ORG environment variable or ensure git remote is configured."
153                .to_string(),
154        ))
155    }
156
157    /// Extracts organization from a git repository's origin remote.
158    fn extract_org_from_repo_path(path: &std::path::Path) -> Option<String> {
159        let repo = git2::Repository::open(path).ok()?;
160        let remote = repo.find_remote("origin").ok()?;
161        let url = remote.url()?;
162        Self::extract_org_from_git_url(url)
163    }
164
165    /// Extracts organization name from a git URL.
166    fn extract_org_from_git_url(url: &str) -> Option<String> {
167        // Handle SSH format: git@github.com:org/repo.git
168        if let Some((rest, path_start)) = url
169            .strip_prefix("git@")
170            .and_then(|rest| rest.find(':').map(|path_start| (rest, path_start)))
171        {
172            let path = &rest[path_start + 1..];
173            return path.split('/').next().map(ToString::to_string);
174        }
175
176        // Handle HTTPS/SSH URL format
177        let path = url
178            .strip_prefix("https://")
179            .or_else(|| url.strip_prefix("http://"))
180            .or_else(|| url.strip_prefix("ssh://"))
181            .or_else(|| url.strip_prefix("git://"));
182
183        if let Some(org) = path.and_then(|rest| rest.split('/').nth(1)) {
184            return Some(org.to_string());
185        }
186
187        None
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_context_template_backend_type_default() {
197        let default = ContextTemplateBackendType::default();
198        assert_eq!(default, ContextTemplateBackendType::Sqlite);
199    }
200
201    #[test]
202    fn test_create_in_memory() {
203        let storage = ContextTemplateStorageFactory::create_in_memory();
204        assert!(storage.is_ok());
205    }
206
207    #[test]
208    fn test_create_with_path() {
209        let dir = tempfile::TempDir::new().unwrap();
210        let db_path = dir.path().join("context_templates.db");
211
212        let storage = ContextTemplateStorageFactory::create_with_path(db_path);
213        assert!(storage.is_ok());
214    }
215
216    #[test]
217    fn test_extract_org_from_git_url() {
218        // HTTPS format
219        assert_eq!(
220            ContextTemplateStorageFactory::extract_org_from_git_url(
221                "https://github.com/zircote/subcog.git"
222            ),
223            Some("zircote".to_string())
224        );
225
226        // SSH format
227        assert_eq!(
228            ContextTemplateStorageFactory::extract_org_from_git_url(
229                "git@github.com:zircote/subcog.git"
230            ),
231            Some("zircote".to_string())
232        );
233
234        // Invalid URL
235        assert_eq!(
236            ContextTemplateStorageFactory::extract_org_from_git_url("not-a-url"),
237            None
238        );
239    }
240}