1use 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#[derive(Debug, Clone, Default)]
56pub struct ContextTemplateFilter {
57 pub domain: Option<DomainScope>,
59 pub tags: Vec<String>,
61 pub name_pattern: Option<String>,
63 pub limit: Option<usize>,
65}
66
67impl ContextTemplateFilter {
68 #[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 #[must_use]
81 pub const fn with_domain(mut self, domain: DomainScope) -> Self {
82 self.domain = Some(domain);
83 self
84 }
85
86 #[must_use]
88 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
89 self.tags = tags;
90 self
91 }
92
93 #[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 #[must_use]
102 pub const fn with_limit(mut self, limit: usize) -> Self {
103 self.limit = Some(limit);
104 self
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct RenderResult {
111 pub output: String,
113 pub format: OutputFormat,
115 pub template_name: String,
117 pub template_version: u32,
119}
120
121pub struct ContextTemplateService {
123 config: SubcogConfig,
125 storage_cache: HashMap<DomainScope, Arc<dyn ContextTemplateStorage>>,
127 renderer: TemplateRenderer,
129}
130
131impl ContextTemplateService {
132 #[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 #[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 fn get_storage(&mut self, domain: DomainScope) -> Result<Arc<dyn ContextTemplateStorage>> {
154 if let Some(storage) = self.storage_cache.get(&domain) {
156 return Ok(Arc::clone(storage));
157 }
158
159 let storage = ContextTemplateStorageFactory::create_for_scope(domain, &self.config)?;
161
162 self.storage_cache.insert(domain, Arc::clone(&storage));
164
165 Ok(storage)
166 }
167
168 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 pub fn save_default(&mut self, template: &ContextTemplate) -> Result<(String, u32)> {
200 self.save(template, DomainScope::User)
201 }
202
203 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 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 results.sort_by(|a, b| a.name.cmp(&b.name));
272
273 if let Some(limit) = filter.limit {
275 results.truncate(limit);
276 }
277
278 Ok(results)
279 }
280
281 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 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 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 let template = self
354 .get(template_name, version, None)?
355 .ok_or_else(|| Error::InvalidInput(format!("Template not found: {template_name}")))?;
356
357 let context = self.build_render_context(memories, statistics, custom_vars, &template)?;
359
360 let output_format = format.unwrap_or(template.output_format);
362
363 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 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 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 context.set(
406 "total_count",
407 RenderValue::String(statistics.total_count.to_string()),
408 );
409
410 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 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 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 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 for (key, value) in custom_vars {
457 context.set(key, RenderValue::String(value.clone()));
458 }
459
460 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 pub fn validate(&self, template: &ContextTemplate) -> Result<ValidationResult> {
488 let mut issues = Vec::new();
489
490 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 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
527pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571pub enum ValidationSeverity {
572 Error,
574 Warning,
576}
577
578#[derive(Debug, Clone)]
580pub struct ValidationIssue {
581 pub severity: ValidationSeverity,
583 pub message: String,
585}
586
587#[derive(Debug, Clone)]
589pub struct ValidationResult {
590 pub is_valid: bool,
592 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 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 let valid = ContextTemplate::new("test", "{{#each memories}}{{memory.content}}{{/each}}");
687 let result = service.validate(&valid).unwrap();
688 assert!(result.is_valid);
689
690 let invalid = ContextTemplate::new("test", "{{#each memories}}content");
692 let result = service.validate(&invalid).unwrap();
693 assert!(!result.is_valid);
694 }
695}