Skip to main content

subcog/services/
context_template.rs

1//! Context template storage, management, and rendering service.
2//!
3//! Provides CRUD operations for user-defined context templates with versioning,
4//! plus rendering capabilities for formatting memories and statistics.
5//!
6//! # Key Features
7//!
8//! - **Auto-increment versioning**: Each save creates a new version
9//! - **Template rendering**: Variable substitution, iteration, format conversion
10//! - **Multiple output formats**: Markdown, JSON, XML
11//! - **Auto-variables**: Memory fields and statistics automatically populated
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use subcog::services::ContextTemplateService;
17//! use subcog::models::{ContextTemplate, OutputFormat};
18//!
19//! let mut service = ContextTemplateService::new();
20//!
21//! // Create a template
22//! let template = ContextTemplate::new("search-results", r#"
23//! # Results
24//! Found {{total_count}} memories:
25//! {{#each memories}}
26//! - {{memory.content}}
27//! {{/each}}
28//! "#);
29//!
30//! // Save it (auto-increments version)
31//! let (name, version) = service.save(&template)?;
32//!
33//! // Render with memories
34//! let output = service.render_with_memories(
35//!     "search-results",
36//!     None, // latest version
37//!     &memories,
38//!     &statistics,
39//!     &HashMap::new(),
40//!     OutputFormat::Markdown,
41//! )?;
42//! ```
43
44use crate::config::SubcogConfig;
45use crate::models::{ContextTemplate, Memory, OutputFormat, VariableType};
46use crate::rendering::{RenderContext, RenderValue, TemplateRenderer};
47use crate::services::MemoryStatistics;
48use crate::storage::context_template::{ContextTemplateStorage, ContextTemplateStorageFactory};
49use crate::storage::index::DomainScope;
50use crate::{Error, Result};
51use std::collections::HashMap;
52use std::sync::Arc;
53
54/// Filter for listing context templates.
55#[derive(Debug, Clone, Default)]
56pub struct ContextTemplateFilter {
57    /// Domain scope to filter by.
58    pub domain: Option<DomainScope>,
59    /// Tags to filter by (AND logic - must have all).
60    pub tags: Vec<String>,
61    /// Name pattern (glob-style).
62    pub name_pattern: Option<String>,
63    /// Maximum number of results.
64    pub limit: Option<usize>,
65}
66
67impl ContextTemplateFilter {
68    /// Creates a new empty filter.
69    #[must_use]
70    pub const fn new() -> Self {
71        Self {
72            domain: None,
73            tags: Vec::new(),
74            name_pattern: None,
75            limit: None,
76        }
77    }
78
79    /// Filters by domain scope.
80    #[must_use]
81    pub const fn with_domain(mut self, domain: DomainScope) -> Self {
82        self.domain = Some(domain);
83        self
84    }
85
86    /// Filters by tags (AND logic).
87    #[must_use]
88    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
89        self.tags = tags;
90        self
91    }
92
93    /// Filters by name pattern.
94    #[must_use]
95    pub fn with_name_pattern(mut self, pattern: impl Into<String>) -> Self {
96        self.name_pattern = Some(pattern.into());
97        self
98    }
99
100    /// Limits results.
101    #[must_use]
102    pub const fn with_limit(mut self, limit: usize) -> Self {
103        self.limit = Some(limit);
104        self
105    }
106}
107
108/// Result of a render operation.
109#[derive(Debug, Clone)]
110pub struct RenderResult {
111    /// The rendered output.
112    pub output: String,
113    /// The output format used.
114    pub format: OutputFormat,
115    /// The template name used.
116    pub template_name: String,
117    /// The template version used.
118    pub template_version: u32,
119}
120
121/// Service for context template CRUD operations and rendering.
122pub struct ContextTemplateService {
123    /// Full subcog configuration.
124    config: SubcogConfig,
125    /// Cached storage backends per domain.
126    storage_cache: HashMap<DomainScope, Arc<dyn ContextTemplateStorage>>,
127    /// Template renderer instance.
128    renderer: TemplateRenderer,
129}
130
131impl ContextTemplateService {
132    /// Creates a new context template service with default configuration.
133    #[must_use]
134    pub fn new() -> Self {
135        Self {
136            config: SubcogConfig::load_default(),
137            storage_cache: HashMap::new(),
138            renderer: TemplateRenderer::new(),
139        }
140    }
141
142    /// Creates a new context template service with custom configuration.
143    #[must_use]
144    pub fn with_config(config: SubcogConfig) -> Self {
145        Self {
146            config,
147            storage_cache: HashMap::new(),
148            renderer: TemplateRenderer::new(),
149        }
150    }
151
152    /// Gets the storage backend for a domain scope.
153    fn get_storage(&mut self, domain: DomainScope) -> Result<Arc<dyn ContextTemplateStorage>> {
154        // Check cache first
155        if let Some(storage) = self.storage_cache.get(&domain) {
156            return Ok(Arc::clone(storage));
157        }
158
159        // Create new storage via factory
160        let storage = ContextTemplateStorageFactory::create_for_scope(domain, &self.config)?;
161
162        // Cache it
163        self.storage_cache.insert(domain, Arc::clone(&storage));
164
165        Ok(storage)
166    }
167
168    /// Saves a context template, auto-incrementing the version.
169    ///
170    /// # Arguments
171    ///
172    /// * `template` - The context template to save
173    /// * `domain` - The domain scope to save in (defaults to User)
174    ///
175    /// # Returns
176    ///
177    /// A tuple of (name, version) for the saved template.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if:
182    /// - The template name is empty or invalid
183    /// - Storage fails
184    pub fn save(
185        &mut self,
186        template: &ContextTemplate,
187        domain: DomainScope,
188    ) -> Result<(String, u32)> {
189        validate_template_name(&template.name)?;
190        let storage = self.get_storage(domain)?;
191        storage.save(template)
192    }
193
194    /// Saves a context template to the default domain (User).
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if the template name is invalid or storage fails.
199    pub fn save_default(&mut self, template: &ContextTemplate) -> Result<(String, u32)> {
200        self.save(template, DomainScope::User)
201    }
202
203    /// Gets a context template by name and optional version.
204    ///
205    /// # Arguments
206    ///
207    /// * `name` - The template name to search for
208    /// * `version` - Optional version number (None = latest)
209    /// * `domain` - Optional domain to search (if None, searches User then Project)
210    ///
211    /// # Returns
212    ///
213    /// The context template if found.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if storage access fails.
218    pub fn get(
219        &mut self,
220        name: &str,
221        version: Option<u32>,
222        domain: Option<DomainScope>,
223    ) -> Result<Option<ContextTemplate>> {
224        let scopes = match domain {
225            Some(scope) => vec![scope],
226            None => vec![DomainScope::User, DomainScope::Project],
227        };
228
229        for scope in scopes {
230            let storage = match self.get_storage(scope) {
231                Ok(s) => s,
232                Err(Error::NotImplemented(_) | Error::FeatureNotEnabled(_)) => continue,
233                Err(e) => return Err(e),
234            };
235            if let Some(template) = storage.get(name, version)? {
236                return Ok(Some(template));
237            }
238        }
239
240        Ok(None)
241    }
242
243    /// Lists context templates matching the filter.
244    ///
245    /// Returns only the latest version of each template.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if storage access fails.
250    pub fn list(&mut self, filter: &ContextTemplateFilter) -> Result<Vec<ContextTemplate>> {
251        let mut results = Vec::new();
252
253        let scopes = match filter.domain {
254            Some(scope) => vec![scope],
255            None => vec![DomainScope::User, DomainScope::Project],
256        };
257
258        for scope in scopes {
259            let storage = match self.get_storage(scope) {
260                Ok(s) => s,
261                Err(Error::NotImplemented(_) | Error::FeatureNotEnabled(_)) => continue,
262                Err(e) => return Err(e),
263            };
264
265            let tags = (!filter.tags.is_empty()).then_some(filter.tags.as_slice());
266            let templates = storage.list(tags, filter.name_pattern.as_deref())?;
267            results.extend(templates);
268        }
269
270        // Sort by name
271        results.sort_by(|a, b| a.name.cmp(&b.name));
272
273        // Apply limit
274        if let Some(limit) = filter.limit {
275            results.truncate(limit);
276        }
277
278        Ok(results)
279    }
280
281    /// Deletes a context template by name and optional version.
282    ///
283    /// # Arguments
284    ///
285    /// * `name` - The template name to delete
286    /// * `version` - Optional version (None = delete all versions)
287    /// * `domain` - The domain scope to delete from
288    ///
289    /// # Returns
290    ///
291    /// `true` if any versions were deleted.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if storage access fails.
296    pub fn delete(
297        &mut self,
298        name: &str,
299        version: Option<u32>,
300        domain: DomainScope,
301    ) -> Result<bool> {
302        let storage = self.get_storage(domain)?;
303        storage.delete(name, version)
304    }
305
306    /// Gets all available versions for a template.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if storage access fails.
311    pub fn get_versions(&mut self, name: &str, domain: DomainScope) -> Result<Vec<u32>> {
312        let storage = self.get_storage(domain)?;
313        storage.get_versions(name)
314    }
315
316    /// Renders a template with memories and statistics.
317    ///
318    /// This is the main entry point for template rendering. It:
319    /// 1. Retrieves the template by name/version
320    /// 2. Builds a render context with auto-variables populated
321    /// 3. Renders the template with variable substitution and iteration
322    /// 4. Converts to the requested output format
323    ///
324    /// # Arguments
325    ///
326    /// * `template_name` - Name of the template to render
327    /// * `version` - Optional version (None = latest)
328    /// * `memories` - List of memories to include
329    /// * `statistics` - Memory statistics
330    /// * `custom_vars` - Custom user-defined variables
331    /// * `format` - Output format (or None to use template default)
332    ///
333    /// # Returns
334    ///
335    /// A [`RenderResult`] containing the rendered output.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if:
340    /// - Template not found
341    /// - Required user variables not provided
342    /// - Rendering fails
343    pub fn render_with_memories(
344        &mut self,
345        template_name: &str,
346        version: Option<u32>,
347        memories: &[Memory],
348        statistics: &MemoryStatistics,
349        custom_vars: &HashMap<String, String>,
350        format: Option<OutputFormat>,
351    ) -> Result<RenderResult> {
352        // Get the template
353        let template = self
354            .get(template_name, version, None)?
355            .ok_or_else(|| Error::InvalidInput(format!("Template not found: {template_name}")))?;
356
357        // Build render context with auto-variables
358        let context = self.build_render_context(memories, statistics, custom_vars, &template)?;
359
360        // Determine output format
361        let output_format = format.unwrap_or(template.output_format);
362
363        // Render the template
364        let output = self.renderer.render(&template, &context, output_format)?;
365
366        Ok(RenderResult {
367            output,
368            format: output_format,
369            template_name: template.name,
370            template_version: template.version,
371        })
372    }
373
374    /// Renders a template directly (without loading from storage).
375    ///
376    /// Useful for preview/dry-run scenarios.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if rendering fails or required variables are missing.
381    pub fn render_direct(
382        &self,
383        template: &ContextTemplate,
384        memories: &[Memory],
385        statistics: &MemoryStatistics,
386        custom_vars: &HashMap<String, String>,
387        format: Option<OutputFormat>,
388    ) -> Result<String> {
389        let context = self.build_render_context(memories, statistics, custom_vars, template)?;
390        let output_format = format.unwrap_or(template.output_format);
391        self.renderer.render(template, &context, output_format)
392    }
393
394    /// Builds a render context with auto-variables populated.
395    fn build_render_context(
396        &self,
397        memories: &[Memory],
398        statistics: &MemoryStatistics,
399        custom_vars: &HashMap<String, String>,
400        template: &ContextTemplate,
401    ) -> Result<RenderContext> {
402        let mut context = RenderContext::new();
403
404        // Add auto-variables for statistics
405        context.set(
406            "total_count",
407            RenderValue::String(statistics.total_count.to_string()),
408        );
409
410        // Build namespace counts string
411        let ns_counts: Vec<String> = statistics
412            .namespace_counts
413            .iter()
414            .map(|(k, v)| format!("{k}: {v}"))
415            .collect();
416        context.set(
417            "namespace_counts",
418            RenderValue::String(ns_counts.join(", ")),
419        );
420
421        // Build statistics as formatted string
422        let stats_str = format!(
423            "Total: {}, Namespaces: {{{}}}",
424            statistics.total_count,
425            ns_counts.join(", ")
426        );
427        context.set("statistics", RenderValue::String(stats_str));
428
429        // Add memories as iterable list
430        let memory_list: Vec<HashMap<String, String>> = memories
431            .iter()
432            .enumerate()
433            .map(|(idx, m)| {
434                let mut map = HashMap::new();
435                map.insert("memory.id".to_string(), m.id.as_str().to_string());
436                map.insert("memory.content".to_string(), m.content.clone());
437                map.insert(
438                    "memory.namespace".to_string(),
439                    m.namespace.as_str().to_string(),
440                );
441                map.insert("memory.tags".to_string(), m.tags.join(", "));
442                map.insert("memory.domain".to_string(), m.domain.to_string());
443                map.insert("memory.created_at".to_string(), m.created_at.to_string());
444                map.insert("memory.updated_at".to_string(), m.updated_at.to_string());
445                // Score is not part of Memory, use index as placeholder
446                map.insert(
447                    "memory.score".to_string(),
448                    format!("{:.2}", (idx as f64).mul_add(-0.01, 1.0)),
449                );
450                map
451            })
452            .collect();
453        context.set("memories", RenderValue::List(memory_list));
454
455        // Add custom user variables
456        for (key, value) in custom_vars {
457            context.set(key, RenderValue::String(value.clone()));
458        }
459
460        // Validate required user variables are provided
461        for var in &template.variables {
462            if var.var_type == VariableType::User
463                && var.required
464                && !custom_vars.contains_key(&var.name)
465                && var.default.is_none()
466            {
467                return Err(Error::InvalidInput(format!(
468                    "Required variable '{}' not provided",
469                    var.name
470                )));
471            }
472        }
473
474        Ok(context)
475    }
476
477    /// Validates template content without saving.
478    ///
479    /// Checks for:
480    /// - Valid variable syntax
481    /// - Balanced iteration blocks
482    /// - Known auto-variables
483    ///
484    /// # Errors
485    ///
486    /// This function currently does not return errors (returns Ok with validation result).
487    pub fn validate(&self, template: &ContextTemplate) -> Result<ValidationResult> {
488        let mut issues = Vec::new();
489
490        // Check iteration blocks are balanced
491        let open_count = template.content.matches("{{#each").count();
492        let close_count = template.content.matches("{{/each}}").count();
493        if open_count != close_count {
494            issues.push(ValidationIssue {
495                severity: ValidationSeverity::Error,
496                message: format!(
497                    "Unbalanced iteration blocks: {open_count} opens, {close_count} closes"
498                ),
499            });
500        }
501
502        // Check for unknown variables (warning only)
503        for var in &template.variables {
504            if var.var_type == VariableType::User && var.default.is_none() && !var.required {
505                issues.push(ValidationIssue {
506                    severity: ValidationSeverity::Warning,
507                    message: format!("Variable '{}' is optional with no default", var.name),
508                });
509            }
510        }
511
512        Ok(ValidationResult {
513            is_valid: !issues
514                .iter()
515                .any(|i| i.severity == ValidationSeverity::Error),
516            issues,
517        })
518    }
519}
520
521impl Default for ContextTemplateService {
522    fn default() -> Self {
523        Self::new()
524    }
525}
526
527/// Validates a template name.
528///
529/// Valid names must be kebab-case: lowercase letters, numbers, and hyphens only.
530pub fn validate_template_name(name: &str) -> Result<()> {
531    if name.is_empty() {
532        return Err(Error::InvalidInput(
533            "Template name cannot be empty. Use a kebab-case name like 'search-results'."
534                .to_string(),
535        ));
536    }
537
538    let first_char = name.chars().next().unwrap_or('_');
539    if !first_char.is_ascii_lowercase() {
540        return Err(Error::InvalidInput(format!(
541            "Template name must start with a lowercase letter, got '{name}'."
542        )));
543    }
544
545    for ch in name.chars() {
546        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
547            return Err(Error::InvalidInput(format!(
548                "Invalid character '{ch}' in template name '{name}'. \
549                 Use kebab-case: lowercase letters, numbers, and hyphens only."
550            )));
551        }
552    }
553
554    if name.ends_with('-') {
555        return Err(Error::InvalidInput(format!(
556            "Template name cannot end with a hyphen: '{name}'."
557        )));
558    }
559
560    if name.contains("--") {
561        return Err(Error::InvalidInput(format!(
562            "Template name cannot have consecutive hyphens: '{name}'."
563        )));
564    }
565
566    Ok(())
567}
568
569/// Validation severity level.
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571pub enum ValidationSeverity {
572    /// Error - prevents template from being used.
573    Error,
574    /// Warning - template can be used but may have issues.
575    Warning,
576}
577
578/// A validation issue found in a template.
579#[derive(Debug, Clone)]
580pub struct ValidationIssue {
581    /// Severity of the issue.
582    pub severity: ValidationSeverity,
583    /// Human-readable description.
584    pub message: String,
585}
586
587/// Result of template validation.
588#[derive(Debug, Clone)]
589pub struct ValidationResult {
590    /// Whether the template is valid (no errors).
591    pub is_valid: bool,
592    /// List of issues found.
593    pub issues: Vec<ValidationIssue>,
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_validate_template_name_valid() {
602        assert!(validate_template_name("search-results").is_ok());
603        assert!(validate_template_name("my-template-v2").is_ok());
604        assert!(validate_template_name("simple").is_ok());
605    }
606
607    #[test]
608    fn test_validate_template_name_invalid() {
609        assert!(validate_template_name("").is_err());
610        assert!(validate_template_name("1invalid").is_err());
611        assert!(validate_template_name("-invalid").is_err());
612        assert!(validate_template_name("Invalid").is_err());
613        assert!(validate_template_name("invalid_name").is_err());
614        assert!(validate_template_name("invalid-").is_err());
615        assert!(validate_template_name("invalid--name").is_err());
616    }
617
618    #[test]
619    fn test_context_template_filter_builder() {
620        let filter = ContextTemplateFilter::new()
621            .with_domain(DomainScope::User)
622            .with_tags(vec!["formatting".to_string()])
623            .with_name_pattern("search-*")
624            .with_limit(10);
625
626        assert_eq!(filter.domain, Some(DomainScope::User));
627        assert_eq!(filter.tags, vec!["formatting"]);
628        assert_eq!(filter.name_pattern, Some("search-*".to_string()));
629        assert_eq!(filter.limit, Some(10));
630    }
631
632    #[test]
633    fn test_build_render_context() {
634        use crate::models::{Domain, MemoryId, MemoryStatus, Namespace};
635
636        let service = ContextTemplateService::new();
637        let template = ContextTemplate::new("test", "{{total_count}} memories");
638
639        let memories = vec![Memory {
640            id: MemoryId::new("test-memory-1"),
641            content: "Test memory".to_string(),
642            namespace: Namespace::Decisions,
643            domain: Domain::new(),
644            project_id: None,
645            branch: None,
646            file_path: None,
647            status: MemoryStatus::Active,
648            created_at: 0,
649            updated_at: 0,
650            tombstoned_at: None,
651            expires_at: None,
652            embedding: None,
653            tags: vec!["test".to_string()],
654            #[cfg(feature = "group-scope")]
655            group_id: None,
656            source: None,
657            is_summary: false,
658            source_memory_ids: None,
659            consolidation_timestamp: None,
660        }];
661
662        let mut namespace_counts = HashMap::new();
663        namespace_counts.insert("decisions".to_string(), 1);
664        let statistics = MemoryStatistics {
665            total_count: 1,
666            namespace_counts,
667            top_tags: vec![],
668            recent_topics: vec![],
669        };
670
671        let custom_vars = HashMap::new();
672        let context = service
673            .build_render_context(&memories, &statistics, &custom_vars, &template)
674            .unwrap();
675
676        // Verify auto-variables are set
677        assert!(context.get("total_count").is_some());
678        assert!(context.get("memories").is_some());
679    }
680
681    #[test]
682    fn test_validate_template() {
683        let service = ContextTemplateService::new();
684
685        // Valid template
686        let valid = ContextTemplate::new("test", "{{#each memories}}{{memory.content}}{{/each}}");
687        let result = service.validate(&valid).unwrap();
688        assert!(result.is_valid);
689
690        // Unbalanced iteration
691        let invalid = ContextTemplate::new("test", "{{#each memories}}content");
692        let result = service.validate(&invalid).unwrap();
693        assert!(!result.is_valid);
694    }
695}