Skip to main content

subcog/storage/context_template/
sqlite.rs

1//! SQLite-based context template storage with versioning.
2//!
3//! Stores context templates in `~/.config/subcog/memories.db` with
4//! automatic version incrementing on save.
5
6use super::ContextTemplateStorage;
7use crate::models::{ContextTemplate, OutputFormat, TemplateVariable};
8use crate::{Error, Result};
9use rusqlite::{Connection, OptionalExtension, params};
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12
13/// `SQLite`-based context template storage with versioning support.
14pub struct SqliteContextTemplateStorage {
15    /// Connection to the `SQLite` database.
16    conn: Mutex<Connection>,
17    /// Path to the `SQLite` database.
18    db_path: PathBuf,
19}
20
21impl SqliteContextTemplateStorage {
22    /// Creates a new `SQLite` context template storage.
23    ///
24    /// # Arguments
25    ///
26    /// * `db_path` - Path to the `SQLite` database file
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if the database cannot be opened or initialized.
31    pub fn new(db_path: impl Into<PathBuf>) -> Result<Self> {
32        let db_path = db_path.into();
33
34        // Ensure parent directory exists
35        if let Some(parent) = db_path.parent() {
36            std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
37                operation: "create_context_template_dir".to_string(),
38                cause: e.to_string(),
39            })?;
40        }
41
42        let conn = Connection::open(&db_path).map_err(|e| Error::OperationFailed {
43            operation: "open_context_template_db".to_string(),
44            cause: e.to_string(),
45        })?;
46
47        let storage = Self {
48            conn: Mutex::new(conn),
49            db_path,
50        };
51
52        storage.initialize()?;
53        Ok(storage)
54    }
55
56    /// Creates an in-memory `SQLite` storage (useful for testing).
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the database cannot be initialized.
61    pub fn in_memory() -> Result<Self> {
62        let conn = Connection::open_in_memory().map_err(|e| Error::OperationFailed {
63            operation: "open_context_template_db_memory".to_string(),
64            cause: e.to_string(),
65        })?;
66
67        let storage = Self {
68            conn: Mutex::new(conn),
69            db_path: PathBuf::from(":memory:"),
70        };
71
72        storage.initialize()?;
73        Ok(storage)
74    }
75
76    /// Returns the default user-scope database path.
77    ///
78    /// Returns `~/.config/subcog/memories.db`.
79    #[must_use]
80    pub fn default_user_path() -> Option<PathBuf> {
81        directories::BaseDirs::new().map(|d| {
82            d.home_dir()
83                .join(".config")
84                .join("subcog")
85                .join("memories.db")
86        })
87    }
88
89    /// Returns the default org-scope database path.
90    ///
91    /// Returns `~/.config/subcog/orgs/{org}/memories.db`.
92    #[must_use]
93    pub fn default_org_path(org: &str) -> Option<PathBuf> {
94        directories::BaseDirs::new().map(|d| {
95            d.home_dir()
96                .join(".config")
97                .join("subcog")
98                .join("orgs")
99                .join(org)
100                .join("memories.db")
101        })
102    }
103
104    /// Returns the database path.
105    #[must_use]
106    pub fn db_path(&self) -> &Path {
107        &self.db_path
108    }
109
110    /// Initializes the database schema and configures pragmas.
111    fn initialize(&self) -> Result<()> {
112        let conn = self.conn.lock().map_err(|e| Error::OperationFailed {
113            operation: "lock_context_template_db".to_string(),
114            cause: e.to_string(),
115        })?;
116
117        // Configure SQLite pragmas for performance and reliability
118        let _ = conn.pragma_update(None, "journal_mode", "WAL");
119        let _ = conn.pragma_update(None, "synchronous", "NORMAL");
120        let _ = conn.pragma_update(None, "busy_timeout", "5000");
121
122        conn.execute(
123            "CREATE TABLE IF NOT EXISTS context_templates (
124                name TEXT NOT NULL,
125                version INTEGER NOT NULL,
126                description TEXT NOT NULL DEFAULT '',
127                content TEXT NOT NULL,
128                variables TEXT NOT NULL DEFAULT '[]',
129                tags TEXT NOT NULL DEFAULT '[]',
130                output_format TEXT NOT NULL DEFAULT 'markdown',
131                author TEXT,
132                created_at INTEGER NOT NULL,
133                updated_at INTEGER NOT NULL,
134                PRIMARY KEY (name, version)
135            )",
136            [],
137        )
138        .map_err(|e| Error::OperationFailed {
139            operation: "create_context_templates_table".to_string(),
140            cause: e.to_string(),
141        })?;
142
143        // Create index for efficient version lookups
144        conn.execute(
145            "CREATE INDEX IF NOT EXISTS idx_context_templates_name_version
146             ON context_templates(name, version DESC)",
147            [],
148        )
149        .map_err(|e| Error::OperationFailed {
150            operation: "create_context_templates_index".to_string(),
151            cause: e.to_string(),
152        })?;
153
154        // Create index on tags for faster filtering
155        conn.execute(
156            "CREATE INDEX IF NOT EXISTS idx_context_templates_tags
157             ON context_templates(tags)",
158            [],
159        )
160        .map_err(|e| Error::OperationFailed {
161            operation: "create_context_templates_tags_index".to_string(),
162            cause: e.to_string(),
163        })?;
164
165        Ok(())
166    }
167
168    /// Locks the connection and returns a guard.
169    fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
170        self.conn.lock().map_err(|e| Error::OperationFailed {
171            operation: "lock_context_template_db".to_string(),
172            cause: e.to_string(),
173        })
174    }
175
176    /// Runs database maintenance (VACUUM and ANALYZE).
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if maintenance commands fail.
181    pub fn vacuum_and_analyze(&self) -> Result<()> {
182        let conn = self.lock_conn()?;
183
184        conn.execute("VACUUM", [])
185            .map_err(|e| Error::OperationFailed {
186                operation: "context_template_db_vacuum".to_string(),
187                cause: e.to_string(),
188            })?;
189
190        conn.execute("ANALYZE", [])
191            .map_err(|e| Error::OperationFailed {
192                operation: "context_template_db_analyze".to_string(),
193                cause: e.to_string(),
194            })?;
195
196        Ok(())
197    }
198
199    /// Returns database statistics for monitoring.
200    #[must_use]
201    pub fn stats(&self) -> Option<ContextTemplateDbStats> {
202        let conn = self.lock_conn().ok()?;
203
204        let template_count: i64 = conn
205            .query_row(
206                "SELECT COUNT(DISTINCT name) FROM context_templates",
207                [],
208                |row| row.get(0),
209            )
210            .unwrap_or(0);
211
212        let version_count: i64 = conn
213            .query_row("SELECT COUNT(*) FROM context_templates", [], |row| {
214                row.get(0)
215            })
216            .unwrap_or(0);
217
218        let page_count: i64 = conn
219            .pragma_query_value(None, "page_count", |row| row.get(0))
220            .unwrap_or(0);
221
222        let page_size: i64 = conn
223            .pragma_query_value(None, "page_size", |row| row.get(0))
224            .unwrap_or(4096);
225
226        Some(ContextTemplateDbStats {
227            template_count: u64::try_from(template_count).unwrap_or(0),
228            version_count: u64::try_from(version_count).unwrap_or(0),
229            db_size_bytes: u64::try_from(page_count.saturating_mul(page_size)).unwrap_or(0),
230        })
231    }
232
233    /// Parses output format from string.
234    fn parse_output_format(s: &str) -> OutputFormat {
235        match s.to_lowercase().as_str() {
236            "json" => OutputFormat::Json,
237            "xml" => OutputFormat::Xml,
238            _ => OutputFormat::Markdown,
239        }
240    }
241
242    /// Converts output format to string.
243    const fn format_to_string(format: OutputFormat) -> &'static str {
244        match format {
245            OutputFormat::Markdown => "markdown",
246            OutputFormat::Json => "json",
247            OutputFormat::Xml => "xml",
248        }
249    }
250}
251
252/// Database statistics for context template storage.
253#[derive(Debug, Clone, Copy, Default)]
254pub struct ContextTemplateDbStats {
255    /// Number of unique templates stored.
256    pub template_count: u64,
257    /// Total number of template versions stored.
258    pub version_count: u64,
259    /// Total database size in bytes.
260    pub db_size_bytes: u64,
261}
262
263impl ContextTemplateStorage for SqliteContextTemplateStorage {
264    #[allow(clippy::cast_possible_wrap)]
265    fn save(&self, template: &ContextTemplate) -> Result<(String, u32)> {
266        let conn = self.lock_conn()?;
267
268        // Get the next version number (max + 1, or 1 if new)
269        let next_version: u32 = conn
270            .query_row(
271                "SELECT COALESCE(MAX(version), 0) + 1 FROM context_templates WHERE name = ?1",
272                params![template.name],
273                |row| row.get::<_, u32>(0),
274            )
275            .map_err(|e| Error::OperationFailed {
276                operation: "get_next_version".to_string(),
277                cause: e.to_string(),
278            })?;
279
280        let variables_json =
281            serde_json::to_string(&template.variables).map_err(|e| Error::OperationFailed {
282                operation: "serialize_variables".to_string(),
283                cause: e.to_string(),
284            })?;
285
286        let tags_json =
287            serde_json::to_string(&template.tags).map_err(|e| Error::OperationFailed {
288                operation: "serialize_tags".to_string(),
289                cause: e.to_string(),
290            })?;
291
292        let now = crate::current_timestamp();
293        let output_format = Self::format_to_string(template.output_format);
294
295        conn.execute(
296            "INSERT INTO context_templates
297             (name, version, description, content, variables, tags, output_format, author, created_at, updated_at)
298             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
299            params![
300                template.name,
301                next_version,
302                template.description,
303                template.content,
304                variables_json,
305                tags_json,
306                output_format,
307                template.author,
308                now as i64,
309            ],
310        )
311        .map_err(|e| Error::OperationFailed {
312            operation: "save_context_template".to_string(),
313            cause: e.to_string(),
314        })?;
315
316        Ok((template.name.clone(), next_version))
317    }
318
319    #[allow(clippy::cast_sign_loss, clippy::option_if_let_else)]
320    fn get(&self, name: &str, version: Option<u32>) -> Result<Option<ContextTemplate>> {
321        let conn = self.lock_conn()?;
322
323        let sql = if version.is_some() {
324            "SELECT name, version, description, content, variables, tags, output_format, author, created_at, updated_at
325             FROM context_templates WHERE name = ?1 AND version = ?2"
326        } else {
327            "SELECT name, version, description, content, variables, tags, output_format, author, created_at, updated_at
328             FROM context_templates WHERE name = ?1 ORDER BY version DESC LIMIT 1"
329        };
330
331        let result = if let Some(v) = version {
332            conn.query_row(sql, params![name, v], |row| {
333                Ok((
334                    row.get::<_, String>(0)?,
335                    row.get::<_, u32>(1)?,
336                    row.get::<_, String>(2)?,
337                    row.get::<_, String>(3)?,
338                    row.get::<_, String>(4)?,
339                    row.get::<_, String>(5)?,
340                    row.get::<_, String>(6)?,
341                    row.get::<_, Option<String>>(7)?,
342                    row.get::<_, i64>(8)?,
343                    row.get::<_, i64>(9)?,
344                ))
345            })
346        } else {
347            conn.query_row(sql, params![name], |row| {
348                Ok((
349                    row.get::<_, String>(0)?,
350                    row.get::<_, u32>(1)?,
351                    row.get::<_, String>(2)?,
352                    row.get::<_, String>(3)?,
353                    row.get::<_, String>(4)?,
354                    row.get::<_, String>(5)?,
355                    row.get::<_, String>(6)?,
356                    row.get::<_, Option<String>>(7)?,
357                    row.get::<_, i64>(8)?,
358                    row.get::<_, i64>(9)?,
359                ))
360            })
361        }
362        .optional()
363        .map_err(|e| Error::OperationFailed {
364            operation: "get_context_template".to_string(),
365            cause: e.to_string(),
366        })?;
367
368        match result {
369            Some((
370                name,
371                version,
372                description,
373                content,
374                variables_json,
375                tags_json,
376                output_format,
377                author,
378                created_at,
379                updated_at,
380            )) => {
381                let variables: Vec<TemplateVariable> =
382                    serde_json::from_str(&variables_json).unwrap_or_default();
383                let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
384
385                Ok(Some(ContextTemplate {
386                    name,
387                    version,
388                    description,
389                    content,
390                    variables,
391                    tags,
392                    output_format: Self::parse_output_format(&output_format),
393                    author,
394                    created_at: created_at as u64,
395                    updated_at: updated_at as u64,
396                }))
397            },
398            None => Ok(None),
399        }
400    }
401
402    #[allow(clippy::cast_sign_loss)]
403    fn list(
404        &self,
405        tags: Option<&[String]>,
406        name_pattern: Option<&str>,
407    ) -> Result<Vec<ContextTemplate>> {
408        let conn = self.lock_conn()?;
409
410        // Get latest version of each template
411        let mut sql = String::from(
412            "SELECT ct.name, ct.version, ct.description, ct.content, ct.variables, ct.tags,
413                    ct.output_format, ct.author, ct.created_at, ct.updated_at
414             FROM context_templates ct
415             INNER JOIN (
416                 SELECT name, MAX(version) as max_version
417                 FROM context_templates
418                 GROUP BY name
419             ) latest ON ct.name = latest.name AND ct.version = latest.max_version
420             WHERE 1=1",
421        );
422        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
423
424        // Add name pattern filter
425        if let Some(pattern) = name_pattern {
426            let like_pattern = pattern.replace('*', "%").replace('?', "_");
427            sql.push_str(" AND ct.name LIKE ?");
428            params_vec.push(Box::new(like_pattern));
429        }
430
431        sql.push_str(" ORDER BY ct.name ASC");
432
433        let mut stmt = conn.prepare(&sql).map_err(|e| Error::OperationFailed {
434            operation: "prepare_list_context_templates".to_string(),
435            cause: e.to_string(),
436        })?;
437
438        let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(AsRef::as_ref).collect();
439
440        let rows = stmt
441            .query_map(params_refs.as_slice(), |row| {
442                Ok((
443                    row.get::<_, String>(0)?,
444                    row.get::<_, u32>(1)?,
445                    row.get::<_, String>(2)?,
446                    row.get::<_, String>(3)?,
447                    row.get::<_, String>(4)?,
448                    row.get::<_, String>(5)?,
449                    row.get::<_, String>(6)?,
450                    row.get::<_, Option<String>>(7)?,
451                    row.get::<_, i64>(8)?,
452                    row.get::<_, i64>(9)?,
453                ))
454            })
455            .map_err(|e| Error::OperationFailed {
456                operation: "list_context_templates".to_string(),
457                cause: e.to_string(),
458            })?;
459
460        let mut results = Vec::new();
461        for row in rows {
462            let (
463                name,
464                version,
465                description,
466                content,
467                variables_json,
468                tags_json,
469                output_format,
470                author,
471                created_at,
472                updated_at,
473            ) = row.map_err(|e| Error::OperationFailed {
474                operation: "read_context_template_row".to_string(),
475                cause: e.to_string(),
476            })?;
477
478            let variables: Vec<TemplateVariable> =
479                serde_json::from_str(&variables_json).unwrap_or_default();
480            let template_tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
481
482            // Filter by tags if specified
483            let has_all_required_tags = tags.is_none_or(|required_tags| {
484                required_tags.iter().all(|t| template_tags.contains(t))
485            });
486            if !has_all_required_tags {
487                continue;
488            }
489
490            results.push(ContextTemplate {
491                name,
492                version,
493                description,
494                content,
495                variables,
496                tags: template_tags,
497                output_format: Self::parse_output_format(&output_format),
498                author,
499                created_at: created_at as u64,
500                updated_at: updated_at as u64,
501            });
502        }
503
504        Ok(results)
505    }
506
507    #[allow(clippy::option_if_let_else)]
508    fn delete(&self, name: &str, version: Option<u32>) -> Result<bool> {
509        let conn = self.lock_conn()?;
510
511        let rows_affected = if let Some(v) = version {
512            conn.execute(
513                "DELETE FROM context_templates WHERE name = ?1 AND version = ?2",
514                params![name, v],
515            )
516        } else {
517            conn.execute(
518                "DELETE FROM context_templates WHERE name = ?1",
519                params![name],
520            )
521        }
522        .map_err(|e| Error::OperationFailed {
523            operation: "delete_context_template".to_string(),
524            cause: e.to_string(),
525        })?;
526
527        Ok(rows_affected > 0)
528    }
529
530    fn get_versions(&self, name: &str) -> Result<Vec<u32>> {
531        let conn = self.lock_conn()?;
532
533        let mut stmt = conn
534            .prepare("SELECT version FROM context_templates WHERE name = ?1 ORDER BY version DESC")
535            .map_err(|e| Error::OperationFailed {
536                operation: "prepare_get_versions".to_string(),
537                cause: e.to_string(),
538            })?;
539
540        let versions = stmt
541            .query_map(params![name], |row| row.get::<_, u32>(0))
542            .map_err(|e| Error::OperationFailed {
543                operation: "get_versions".to_string(),
544                cause: e.to_string(),
545            })?
546            .collect::<std::result::Result<Vec<_>, _>>()
547            .map_err(|e| Error::OperationFailed {
548                operation: "collect_versions".to_string(),
549                cause: e.to_string(),
550            })?;
551
552        Ok(versions)
553    }
554
555    fn get_latest_version(&self, name: &str) -> Result<Option<u32>> {
556        let conn = self.lock_conn()?;
557
558        conn.query_row(
559            "SELECT MAX(version) FROM context_templates WHERE name = ?1",
560            params![name],
561            |row| row.get::<_, Option<u32>>(0),
562        )
563        .map_err(|e| Error::OperationFailed {
564            operation: "get_latest_version".to_string(),
565            cause: e.to_string(),
566        })
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::models::VariableType;
574
575    fn create_test_template(name: &str, content: &str) -> ContextTemplate {
576        ContextTemplate {
577            name: name.to_string(),
578            version: 0, // Will be set by storage
579            description: format!("Test template: {name}"),
580            content: content.to_string(),
581            variables: vec![],
582            tags: vec![],
583            output_format: OutputFormat::Markdown,
584            author: None,
585            created_at: 0,
586            updated_at: 0,
587        }
588    }
589
590    #[test]
591    fn test_sqlite_context_template_storage_creation() {
592        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
593        assert_eq!(storage.db_path().to_str(), Some(":memory:"));
594    }
595
596    #[test]
597    fn test_save_and_get_template() {
598        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
599
600        let template = create_test_template("test-template", "Hello {{name}}!");
601
602        let (name, version) = storage.save(&template).unwrap();
603        assert_eq!(name, "test-template");
604        assert_eq!(version, 1);
605
606        let retrieved = storage.get("test-template", None).unwrap();
607        assert!(retrieved.is_some());
608        let retrieved = retrieved.unwrap();
609        assert_eq!(retrieved.name, "test-template");
610        assert_eq!(retrieved.version, 1);
611        assert_eq!(retrieved.content, "Hello {{name}}!");
612    }
613
614    #[test]
615    fn test_auto_increment_version() {
616        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
617
618        // Save first version
619        let template = create_test_template("versioned", "Version 1");
620        let (_, v1) = storage.save(&template).unwrap();
621        assert_eq!(v1, 1);
622
623        // Save second version
624        let template2 = create_test_template("versioned", "Version 2");
625        let (_, v2) = storage.save(&template2).unwrap();
626        assert_eq!(v2, 2);
627
628        // Save third version
629        let template3 = create_test_template("versioned", "Version 3");
630        let (_, v3) = storage.save(&template3).unwrap();
631        assert_eq!(v3, 3);
632
633        // Get latest should return version 3
634        let latest = storage.get("versioned", None).unwrap().unwrap();
635        assert_eq!(latest.version, 3);
636        assert_eq!(latest.content, "Version 3");
637
638        // Get specific version should work
639        let v1_retrieved = storage.get("versioned", Some(1)).unwrap().unwrap();
640        assert_eq!(v1_retrieved.version, 1);
641        assert_eq!(v1_retrieved.content, "Version 1");
642    }
643
644    #[test]
645    fn test_get_versions() {
646        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
647
648        // Save multiple versions
649        for i in 1..=5 {
650            let template = create_test_template("multi-version", &format!("Content {i}"));
651            storage.save(&template).unwrap();
652        }
653
654        let versions = storage.get_versions("multi-version").unwrap();
655        assert_eq!(versions, vec![5, 4, 3, 2, 1]);
656    }
657
658    #[test]
659    fn test_get_latest_version() {
660        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
661
662        // No template exists
663        assert_eq!(storage.get_latest_version("nonexistent").unwrap(), None);
664
665        // Save some versions
666        for _ in 0..3 {
667            let template = create_test_template("latest-test", "Content");
668            storage.save(&template).unwrap();
669        }
670
671        assert_eq!(storage.get_latest_version("latest-test").unwrap(), Some(3));
672    }
673
674    #[test]
675    fn test_list_templates() {
676        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
677
678        // Save templates with multiple versions
679        for i in 1..=3 {
680            let template = create_test_template("alpha", &format!("Alpha v{i}"));
681            storage.save(&template).unwrap();
682        }
683
684        let mut beta = create_test_template("beta", "Beta content");
685        beta.tags = vec!["tag1".to_string(), "tag2".to_string()];
686        storage.save(&beta).unwrap();
687
688        let gamma = create_test_template("gamma", "Gamma content");
689        storage.save(&gamma).unwrap();
690
691        // List all - should return latest versions only
692        let all = storage.list(None, None).unwrap();
693        assert_eq!(all.len(), 3);
694
695        // Verify we got the latest version of alpha
696        let alpha = all.iter().find(|t| t.name == "alpha").unwrap();
697        assert_eq!(alpha.version, 3);
698
699        // Filter by tag
700        let with_tag1 = storage.list(Some(&["tag1".to_string()]), None).unwrap();
701        assert_eq!(with_tag1.len(), 1);
702        assert_eq!(with_tag1[0].name, "beta");
703
704        // Filter by name pattern
705        let alpha_pattern = storage.list(None, Some("a*")).unwrap();
706        assert_eq!(alpha_pattern.len(), 1);
707        assert_eq!(alpha_pattern[0].name, "alpha");
708    }
709
710    #[test]
711    fn test_delete_specific_version() {
712        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
713
714        // Save multiple versions
715        for _ in 0..3 {
716            let template = create_test_template("to-delete", "Content");
717            storage.save(&template).unwrap();
718        }
719
720        // Delete version 2
721        assert!(storage.delete("to-delete", Some(2)).unwrap());
722
723        // Version 2 should be gone
724        assert!(storage.get("to-delete", Some(2)).unwrap().is_none());
725
726        // Versions 1 and 3 should still exist
727        assert!(storage.get("to-delete", Some(1)).unwrap().is_some());
728        assert!(storage.get("to-delete", Some(3)).unwrap().is_some());
729
730        // Latest should still be version 3
731        let latest = storage.get("to-delete", None).unwrap().unwrap();
732        assert_eq!(latest.version, 3);
733    }
734
735    #[test]
736    fn test_delete_all_versions() {
737        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
738
739        // Save multiple versions
740        for _ in 0..3 {
741            let template = create_test_template("delete-all", "Content");
742            storage.save(&template).unwrap();
743        }
744
745        // Delete all versions
746        assert!(storage.delete("delete-all", None).unwrap());
747
748        // All versions should be gone
749        assert!(storage.get("delete-all", None).unwrap().is_none());
750        assert!(storage.get_versions("delete-all").unwrap().is_empty());
751    }
752
753    #[test]
754    fn test_template_with_variables() {
755        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
756
757        let template = ContextTemplate {
758            name: "with-vars".to_string(),
759            version: 0,
760            description: "Template with variables".to_string(),
761            content: "Hello {{name}}, you have {{count}} items".to_string(),
762            variables: vec![
763                TemplateVariable {
764                    name: "name".to_string(),
765                    var_type: VariableType::User,
766                    description: Some("User's name".to_string()),
767                    default: None,
768                    required: true,
769                },
770                TemplateVariable {
771                    name: "count".to_string(),
772                    var_type: VariableType::User,
773                    description: None,
774                    default: Some("0".to_string()),
775                    required: false,
776                },
777            ],
778            tags: vec!["greeting".to_string()],
779            output_format: OutputFormat::Markdown,
780            author: Some("test-author".to_string()),
781            created_at: 0,
782            updated_at: 0,
783        };
784
785        storage.save(&template).unwrap();
786
787        let retrieved = storage.get("with-vars", None).unwrap().unwrap();
788        assert_eq!(retrieved.variables.len(), 2);
789        assert_eq!(retrieved.variables[0].name, "name");
790        assert!(retrieved.variables[0].required);
791        assert_eq!(retrieved.author, Some("test-author".to_string()));
792    }
793
794    #[test]
795    fn test_output_format_persistence() {
796        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
797
798        // Save with JSON format
799        let mut template = create_test_template("json-format", "{{data}}");
800        template.output_format = OutputFormat::Json;
801        storage.save(&template).unwrap();
802
803        let retrieved = storage.get("json-format", None).unwrap().unwrap();
804        assert!(matches!(retrieved.output_format, OutputFormat::Json));
805
806        // Save with XML format
807        let mut template2 = create_test_template("xml-format", "{{data}}");
808        template2.output_format = OutputFormat::Xml;
809        storage.save(&template2).unwrap();
810
811        let retrieved2 = storage.get("xml-format", None).unwrap().unwrap();
812        assert!(matches!(retrieved2.output_format, OutputFormat::Xml));
813    }
814
815    #[test]
816    fn test_stats() {
817        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
818
819        // Initially empty
820        let stats = storage.stats().unwrap();
821        assert_eq!(stats.template_count, 0);
822        assert_eq!(stats.version_count, 0);
823
824        // Add some templates with versions
825        for _ in 0..3 {
826            let template = create_test_template("stats-test-1", "Content");
827            storage.save(&template).unwrap();
828        }
829        storage
830            .save(&create_test_template("stats-test-2", "Content"))
831            .unwrap();
832
833        let stats = storage.stats().unwrap();
834        assert_eq!(stats.template_count, 2); // 2 unique templates
835        assert_eq!(stats.version_count, 4); // 4 total versions
836        assert!(stats.db_size_bytes > 0);
837    }
838
839    #[test]
840    fn test_vacuum_and_analyze() {
841        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
842
843        // Add and delete some templates
844        for i in 0..10 {
845            storage
846                .save(&create_test_template(&format!("temp-{i}"), "Content"))
847                .unwrap();
848        }
849        for i in 0..10 {
850            storage.delete(&format!("temp-{i}"), None).unwrap();
851        }
852
853        assert!(storage.vacuum_and_analyze().is_ok());
854    }
855
856    #[test]
857    fn test_default_user_path() {
858        let path = SqliteContextTemplateStorage::default_user_path();
859        if let Some(p) = path {
860            assert!(p.to_string_lossy().contains("subcog"));
861            assert!(p.to_string_lossy().ends_with("memories.db"));
862        }
863    }
864
865    #[test]
866    fn test_nonexistent_template() {
867        let storage = SqliteContextTemplateStorage::in_memory().unwrap();
868
869        assert!(storage.get("nonexistent", None).unwrap().is_none());
870        assert!(storage.get("nonexistent", Some(1)).unwrap().is_none());
871        assert!(!storage.delete("nonexistent", None).unwrap());
872    }
873}