Skip to main content

subcog/services/
prompt.rs

1//! Prompt template storage and management service.
2//!
3//! Provides CRUD operations for user-defined prompt templates using
4//! domain-scoped storage backends via [`PromptStorageFactory`].
5//!
6//! # Domain Hierarchy
7//!
8//! Prompts are searched in priority order:
9//! 1. **Project** - Repository-specific prompts (faceted by repo/branch)
10//! 2. **User** - User-wide prompts (`~/.config/subcog/prompts.db`)
11//! 3. **Org** - Organization-wide prompts (deferred)
12//!
13//! # Storage Backends
14//!
15//! | Domain | Backend | Location |
16//! |--------|---------|----------|
17//! | Project | `SQLite` | `~/.config/subcog/prompts.db` (with repo/branch facets) |
18//! | User | `SQLite` | `~/.config/subcog/prompts.db` |
19//! | User | Filesystem | `~/.config/subcog/_prompts/` (fallback) |
20//! | Org | Deferred | Not yet implemented |
21
22use crate::config::{Config, SubcogConfig};
23use crate::models::PromptTemplate;
24use crate::services::prompt_enrichment::{
25    EnrichmentRequest, EnrichmentStatus, PartialMetadata, PromptEnrichmentResult,
26    PromptEnrichmentService,
27};
28use crate::storage::index::DomainScope;
29use crate::storage::prompt::{PromptStorage, PromptStorageFactory};
30use crate::{Error, Result};
31use std::collections::HashMap;
32use std::path::PathBuf;
33use std::sync::Arc;
34
35// Relevance scoring weights for prompt search
36/// Score boost for exact name match.
37const SCORE_EXACT_NAME_MATCH: f32 = 10.0;
38/// Score boost for partial name match.
39const SCORE_PARTIAL_NAME_MATCH: f32 = 5.0;
40/// Score boost for description match.
41const SCORE_DESCRIPTION_MATCH: f32 = 3.0;
42/// Score boost for content match.
43const SCORE_CONTENT_MATCH: f32 = 1.0;
44/// Score boost for tag match.
45const SCORE_TAG_MATCH: f32 = 2.0;
46/// Divisor for usage count boost calculation.
47const USAGE_BOOST_DIVISOR: f32 = 100.0;
48/// Maximum usage boost multiplier.
49const USAGE_BOOST_MAX: f32 = 0.5;
50
51/// Filter for listing prompts.
52#[derive(Debug, Clone, Default)]
53pub struct PromptFilter {
54    /// Domain scope to filter by.
55    pub domain: Option<DomainScope>,
56    /// Tags to filter by (AND logic - must have all).
57    pub tags: Vec<String>,
58    /// Name pattern (glob-style).
59    pub name_pattern: Option<String>,
60    /// Maximum number of results.
61    pub limit: Option<usize>,
62}
63
64impl PromptFilter {
65    /// Creates a new empty filter.
66    #[must_use]
67    pub const fn new() -> Self {
68        Self {
69            domain: None,
70            tags: Vec::new(),
71            name_pattern: None,
72            limit: None,
73        }
74    }
75}
76
77/// Options for saving a prompt with enrichment.
78#[derive(Debug, Clone, Default)]
79pub struct SaveOptions {
80    /// Skip LLM enrichment (use basic metadata extraction only).
81    pub skip_enrichment: bool,
82    /// Dry run - return enriched template without saving.
83    pub dry_run: bool,
84}
85
86impl SaveOptions {
87    /// Creates new default save options.
88    #[must_use]
89    pub const fn new() -> Self {
90        Self {
91            skip_enrichment: false,
92            dry_run: false,
93        }
94    }
95
96    /// Sets the `skip_enrichment` flag.
97    #[must_use]
98    pub const fn with_skip_enrichment(mut self, skip: bool) -> Self {
99        self.skip_enrichment = skip;
100        self
101    }
102
103    /// Sets the `dry_run` flag.
104    #[must_use]
105    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
106        self.dry_run = dry_run;
107        self
108    }
109}
110
111/// Result of a save operation with enrichment.
112#[derive(Debug, Clone)]
113pub struct SaveResult {
114    /// The saved template (with enriched metadata).
115    pub template: PromptTemplate,
116    /// The ID of the saved prompt (empty for dry-run).
117    pub id: String,
118    /// The enrichment status.
119    pub enrichment_status: EnrichmentStatus,
120}
121
122impl PromptFilter {
123    /// Filters by domain scope.
124    #[must_use]
125    pub const fn with_domain(mut self, domain: DomainScope) -> Self {
126        self.domain = Some(domain);
127        self
128    }
129
130    /// Filters by tags (AND logic).
131    #[must_use]
132    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
133        self.tags = tags;
134        self
135    }
136
137    /// Filters by name pattern.
138    #[must_use]
139    pub fn with_name_pattern(mut self, pattern: impl Into<String>) -> Self {
140        self.name_pattern = Some(pattern.into());
141        self
142    }
143
144    /// Limits results.
145    #[must_use]
146    pub const fn with_limit(mut self, limit: usize) -> Self {
147        self.limit = Some(limit);
148        self
149    }
150}
151
152/// Service for prompt template CRUD operations.
153///
154/// Uses [`PromptStorageFactory`] to get domain-scoped storage backends.
155pub struct PromptService {
156    /// Simple configuration (for backwards compatibility).
157    config: Config,
158    /// Full subcog configuration (for storage config).
159    subcog_config: Option<SubcogConfig>,
160    /// Cached storage backends per domain.
161    storage_cache: HashMap<DomainScope, Arc<dyn PromptStorage>>,
162}
163
164impl PromptService {
165    /// Creates a new prompt service.
166    #[must_use]
167    pub fn new(config: Config) -> Self {
168        Self {
169            config,
170            subcog_config: None,
171            storage_cache: HashMap::new(),
172        }
173    }
174
175    /// Creates a new prompt service with full subcog configuration.
176    ///
177    /// This allows the service to use storage settings from the config file.
178    #[must_use]
179    pub fn with_subcog_config(subcog_config: SubcogConfig) -> Self {
180        Self {
181            config: Config::from(subcog_config.clone()),
182            subcog_config: Some(subcog_config),
183            storage_cache: HashMap::new(),
184        }
185    }
186
187    /// Creates a prompt service with a repository path.
188    #[must_use]
189    pub fn with_repo_path(mut self, path: impl Into<PathBuf>) -> Self {
190        self.config.repo_path = Some(path.into());
191        self
192    }
193
194    /// Sets the repository path.
195    pub fn set_repo_path(&mut self, path: impl Into<PathBuf>) {
196        self.config.repo_path = Some(path.into());
197        // Clear cache since repo path changed
198        self.storage_cache.clear();
199    }
200
201    /// Gets the storage backend for a domain scope.
202    fn get_storage(&mut self, domain: DomainScope) -> Result<Arc<dyn PromptStorage>> {
203        // Check cache first
204        if let Some(storage) = self.storage_cache.get(&domain) {
205            return Ok(Arc::clone(storage));
206        }
207
208        // Create new storage via factory
209        // Use SubcogConfig if available (respects config file settings)
210        let storage = if let Some(ref subcog_config) = self.subcog_config {
211            PromptStorageFactory::create_for_scope_with_subcog_config(domain, subcog_config)?
212        } else {
213            PromptStorageFactory::create_for_scope(domain, &self.config)?
214        };
215
216        // Cache it
217        self.storage_cache.insert(domain, Arc::clone(&storage));
218
219        Ok(storage)
220    }
221
222    /// Saves or updates a prompt template.
223    ///
224    /// # Arguments
225    ///
226    /// * `template` - The prompt template to save
227    /// * `domain` - The domain scope to save in (defaults to Project)
228    ///
229    /// # Returns
230    ///
231    /// The unique ID of the saved prompt.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if:
236    /// - The template name is empty or invalid
237    /// - Storage fails
238    ///
239    /// # Example
240    ///
241    /// ```no_run
242    /// use subcog::services::PromptService;
243    /// use subcog::models::PromptTemplate;
244    /// use subcog::storage::index::DomainScope;
245    ///
246    /// let mut service = PromptService::new(Default::default());
247    /// let template = PromptTemplate::new("code-review", "Review {{code}}");
248    /// let id = service.save(&template, DomainScope::Project)?;
249    /// # Ok::<(), subcog::Error>(())
250    /// ```
251    pub fn save(&mut self, template: &PromptTemplate, domain: DomainScope) -> Result<String> {
252        // Validate name
253        validate_prompt_name(&template.name)?;
254
255        // Get storage for domain
256        let storage = self.get_storage(domain)?;
257
258        // Delegate to storage backend
259        storage.save(template)
260    }
261
262    /// Saves a prompt with LLM-powered enrichment.
263    ///
264    /// This method extracts variables from the content, optionally enriches
265    /// with LLM-generated metadata (descriptions, tags, variable info), and
266    /// saves the template.
267    ///
268    /// # Arguments
269    ///
270    /// * `name` - Prompt name (kebab-case).
271    /// * `content` - The prompt template content.
272    /// * `domain` - Domain scope to save in.
273    /// * `options` - Save options (skip enrichment, dry run).
274    /// * `llm` - Optional LLM provider for enrichment.
275    /// * `existing` - Optional existing metadata to preserve.
276    ///
277    /// # Returns
278    ///
279    /// A [`SaveResult`] containing the saved template and enrichment status.
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if the name is invalid or storage fails.
284    /// Enrichment failures are gracefully handled with fallback.
285    pub fn save_with_enrichment<P: crate::llm::LlmProvider>(
286        &mut self,
287        name: &str,
288        content: &str,
289        domain: DomainScope,
290        options: &SaveOptions,
291        llm: Option<P>,
292        existing: Option<PartialMetadata>,
293    ) -> Result<SaveResult> {
294        // Validate name
295        validate_prompt_name(name)?;
296
297        // Extract variables from content (returns ExtractedVariable with name and position)
298        let extracted = crate::models::extract_variables(content);
299        let variable_names: Vec<String> = extracted.iter().map(|v| v.name.clone()).collect();
300
301        // Helper to apply basic fallback with optional user metadata merge
302        let apply_fallback = |vars: &[String], user: Option<&PartialMetadata>| {
303            let mut result = PromptEnrichmentResult::basic_from_variables(vars);
304            if let Some(user_meta) = user {
305                result = result.merge_with_user(user_meta);
306            }
307            result
308        };
309
310        // Perform enrichment or use fallback
311        let enrichment = match (options.skip_enrichment, llm) {
312            // LLM available and enrichment not skipped
313            (false, Some(llm_provider)) => {
314                let service = PromptEnrichmentService::new(llm_provider);
315                let request = EnrichmentRequest::new(content, variable_names)
316                    .with_optional_existing(existing);
317                service.enrich_with_fallback(&request)
318            },
319            // Enrichment skipped or no LLM provider: use basic fallback
320            (true, _) | (false, None) => apply_fallback(&variable_names, existing.as_ref()),
321        };
322
323        // Build the template with enriched metadata
324        let template = PromptTemplate {
325            name: name.to_string(),
326            content: content.to_string(),
327            description: enrichment.description.clone(),
328            tags: enrichment.tags.clone(),
329            variables: enrichment.variables.clone(),
330            ..Default::default()
331        };
332
333        // Save unless dry-run
334        let id = if options.dry_run {
335            String::new()
336        } else {
337            let storage = self.get_storage(domain)?;
338            storage.save(&template)?
339        };
340
341        Ok(SaveResult {
342            template,
343            id,
344            enrichment_status: enrichment.status,
345        })
346    }
347
348    /// Gets a prompt by name, searching domain hierarchy.
349    ///
350    /// Searches in priority order: Project → User → Org
351    ///
352    /// # Arguments
353    ///
354    /// * `name` - The prompt name to search for
355    /// * `domain` - Optional domain to search (if None, searches all)
356    ///
357    /// # Returns
358    ///
359    /// The prompt template if found.
360    ///
361    /// # Errors
362    ///
363    /// Returns an error if storage operations fail.
364    pub fn get(
365        &mut self,
366        name: &str,
367        domain: Option<DomainScope>,
368    ) -> Result<Option<PromptTemplate>> {
369        // Search order based on domain parameter
370        let scopes = match domain {
371            Some(scope) => vec![scope],
372            None => vec![DomainScope::Project, DomainScope::User],
373        };
374
375        for scope in scopes {
376            // Get storage, skipping unimplemented domains (e.g., Org)
377            let storage = match self.get_storage(scope) {
378                Ok(s) => s,
379                Err(Error::NotImplemented(_)) => continue,
380                Err(e) => return Err(e),
381            };
382            if let Some(template) = storage.get(name)? {
383                return Ok(Some(template));
384            }
385        }
386
387        Ok(None)
388    }
389
390    /// Lists prompts matching the filter.
391    ///
392    /// # Arguments
393    ///
394    /// * `filter` - Filter criteria
395    ///
396    /// # Returns
397    ///
398    /// List of matching prompt templates.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if storage operations fail.
403    pub fn list(&mut self, filter: &PromptFilter) -> Result<Vec<PromptTemplate>> {
404        let mut results = Vec::new();
405
406        // Determine which domains to search
407        let scopes = match filter.domain {
408            Some(scope) => vec![scope],
409            None => vec![DomainScope::Project, DomainScope::User],
410        };
411
412        // Collect from all relevant domains
413        for scope in scopes {
414            // Get storage, skipping unimplemented domains
415            let storage = match self.get_storage(scope) {
416                Ok(s) => s,
417                Err(Error::NotImplemented(_)) => continue,
418                Err(e) => return Err(e),
419            };
420
421            let tags = (!filter.tags.is_empty()).then_some(filter.tags.as_slice());
422            let prompts = storage.list(tags, filter.name_pattern.as_deref())?;
423
424            // Filter and collect matching templates
425            results.extend(
426                prompts
427                    .into_iter()
428                    .filter(|t| self.matches_filter(t, filter)),
429            );
430        }
431
432        // Sort by usage count (descending) then name
433        results.sort_by(|a, b| {
434            b.usage_count
435                .cmp(&a.usage_count)
436                .then_with(|| a.name.cmp(&b.name))
437        });
438
439        // Apply limit
440        if let Some(limit) = filter.limit {
441            results.truncate(limit);
442        }
443
444        Ok(results)
445    }
446
447    /// Deletes a prompt by name.
448    ///
449    /// # Arguments
450    ///
451    /// * `name` - The prompt name to delete
452    /// * `domain` - The domain scope to delete from
453    ///
454    /// # Returns
455    ///
456    /// `true` if the prompt was found and deleted.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if storage operations fail.
461    pub fn delete(&mut self, name: &str, domain: DomainScope) -> Result<bool> {
462        let storage = self.get_storage(domain)?;
463        storage.delete(name)
464    }
465
466    /// Searches prompts semantically by query.
467    ///
468    /// # Arguments
469    ///
470    /// * `query` - The search query
471    /// * `limit` - Maximum results
472    ///
473    /// # Returns
474    ///
475    /// List of matching prompt templates, ordered by relevance.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error if storage operations fail.
480    pub fn search(&mut self, query: &str, limit: usize) -> Result<Vec<PromptTemplate>> {
481        // Get all prompts from all domains
482        let all_prompts = self.list(&PromptFilter::new())?;
483
484        let query_lower = query.to_lowercase();
485        let mut scored: Vec<(PromptTemplate, f32)> = all_prompts
486            .into_iter()
487            .map(|t| {
488                let score = self.calculate_relevance(&t, &query_lower);
489                (t, score)
490            })
491            .filter(|(_, score)| *score > 0.0)
492            .collect();
493
494        // Sort by score descending
495        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
496
497        // Take top results
498        Ok(scored.into_iter().take(limit).map(|(t, _)| t).collect())
499    }
500
501    /// Increments the usage count for a prompt.
502    ///
503    /// # Arguments
504    ///
505    /// * `name` - The prompt name
506    /// * `domain` - The domain scope
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if the prompt is not found or storage fails.
511    pub fn increment_usage(&mut self, name: &str, domain: DomainScope) -> Result<()> {
512        let storage = self.get_storage(domain)?;
513        storage.increment_usage(name)?;
514        Ok(())
515    }
516
517    /// Checks if a template matches the filter.
518    fn matches_filter(&self, template: &PromptTemplate, filter: &PromptFilter) -> bool {
519        // Check tags (AND logic)
520        for tag in &filter.tags {
521            if !template.tags.iter().any(|t| t == tag) {
522                return false;
523            }
524        }
525
526        // Check name pattern (simple glob with * wildcard)
527        if let Some(pattern) = filter.name_pattern.as_deref()
528            && !matches_glob(pattern, &template.name)
529        {
530            return false;
531        }
532
533        true
534    }
535
536    /// Calculates relevance score for search.
537    fn calculate_relevance(&self, template: &PromptTemplate, query: &str) -> f32 {
538        let mut score = 0.0f32;
539
540        // Exact name match
541        if template.name.to_lowercase() == query {
542            score += SCORE_EXACT_NAME_MATCH;
543        } else if template.name.to_lowercase().contains(query) {
544            score += SCORE_PARTIAL_NAME_MATCH;
545        }
546
547        // Description match
548        if template.description.to_lowercase().contains(query) {
549            score += SCORE_DESCRIPTION_MATCH;
550        }
551
552        // Content match
553        if template.content.to_lowercase().contains(query) {
554            score += SCORE_CONTENT_MATCH;
555        }
556
557        // Tag match
558        for tag in &template.tags {
559            if tag.to_lowercase().contains(query) {
560                score += SCORE_TAG_MATCH;
561            }
562        }
563
564        // Boost by usage
565        score *= 1.0 + (template.usage_count as f32 / USAGE_BOOST_DIVISOR).min(USAGE_BOOST_MAX);
566
567        score
568    }
569}
570
571impl Default for PromptService {
572    fn default() -> Self {
573        Self::new(Config::default())
574    }
575}
576
577/// Validates a prompt name.
578///
579/// Valid names must be kebab-case: lowercase letters, numbers, and hyphens only.
580/// Must start with a letter, cannot end with a hyphen, and cannot have consecutive hyphens.
581///
582/// Examples of valid names: `code-review`, `api-design-v2`, `weekly-report`
583pub fn validate_prompt_name(name: &str) -> Result<()> {
584    if name.is_empty() {
585        return Err(Error::InvalidInput(
586            "Prompt name cannot be empty. Use a kebab-case name like 'code-review' or 'api-design'."
587                .to_string(),
588        ));
589    }
590
591    // Check for valid kebab-case: lowercase letters, numbers, hyphens
592    // Must start with a letter
593    let first_char = name.chars().next().unwrap_or('_');
594    if !first_char.is_ascii_lowercase() {
595        return Err(Error::InvalidInput(format!(
596            "Prompt name must start with a lowercase letter, got '{name}'. \
597             Example: 'code-review' instead of 'Code-Review' or '1-review'."
598        )));
599    }
600
601    for ch in name.chars() {
602        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
603            return Err(Error::InvalidInput(format!(
604                "Invalid character '{ch}' in prompt name '{name}'. \
605                 Use kebab-case: lowercase letters, numbers, and hyphens only. \
606                 Example: 'my-prompt-v2' instead of 'My_Prompt v2'."
607            )));
608        }
609    }
610
611    // Cannot end with hyphen
612    if name.ends_with('-') {
613        return Err(Error::InvalidInput(format!(
614            "Prompt name cannot end with a hyphen: '{name}'. \
615             Remove the trailing hyphen or add a suffix like '{}-final'.",
616            name.trim_end_matches('-')
617        )));
618    }
619
620    // Cannot have consecutive hyphens
621    if name.contains("--") {
622        return Err(Error::InvalidInput(format!(
623            "Prompt name cannot have consecutive hyphens: '{name}'. \
624             Use single hyphens between words, e.g., 'my-prompt' instead of 'my--prompt'."
625        )));
626    }
627
628    Ok(())
629}
630
631/// Simple glob pattern matching (* only).
632fn matches_glob(pattern: &str, text: &str) -> bool {
633    if !pattern.contains('*') {
634        return pattern == text;
635    }
636
637    let parts: Vec<&str> = pattern.split('*').collect();
638
639    // Handle empty pattern
640    if parts.is_empty() {
641        return true;
642    }
643
644    // Check prefix
645    if !parts[0].is_empty() && !text.starts_with(parts[0]) {
646        return false;
647    }
648
649    // Check suffix
650    let last = parts.last().unwrap_or(&"");
651    if !last.is_empty() && !text.ends_with(last) {
652        return false;
653    }
654
655    // Check all parts exist in order
656    let mut remaining = text;
657    for part in &parts {
658        if part.is_empty() {
659            continue;
660        }
661        if let Some(pos) = remaining.find(part) {
662            remaining = &remaining[pos + part.len()..];
663        } else {
664            return false;
665        }
666    }
667
668    true
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_validate_prompt_name_valid() {
677        assert!(validate_prompt_name("code-review").is_ok());
678        assert!(validate_prompt_name("my-prompt-v2").is_ok());
679        assert!(validate_prompt_name("simple").is_ok());
680        assert!(validate_prompt_name("a1b2c3").is_ok());
681    }
682
683    #[test]
684    fn test_validate_prompt_name_invalid() {
685        // Empty
686        assert!(validate_prompt_name("").is_err());
687
688        // Starts with number
689        assert!(validate_prompt_name("1invalid").is_err());
690
691        // Starts with hyphen
692        assert!(validate_prompt_name("-invalid").is_err());
693
694        // Contains uppercase
695        assert!(validate_prompt_name("Invalid").is_err());
696
697        // Contains underscore
698        assert!(validate_prompt_name("invalid_name").is_err());
699
700        // Ends with hyphen
701        assert!(validate_prompt_name("invalid-").is_err());
702
703        // Consecutive hyphens
704        assert!(validate_prompt_name("invalid--name").is_err());
705
706        // Contains spaces
707        assert!(validate_prompt_name("invalid name").is_err());
708    }
709
710    #[test]
711    fn test_matches_glob() {
712        // Exact match
713        assert!(matches_glob("code-review", "code-review"));
714        assert!(!matches_glob("code-review", "other"));
715
716        // Prefix match
717        assert!(matches_glob("code-*", "code-review"));
718        assert!(matches_glob("code-*", "code-fix"));
719        assert!(!matches_glob("code-*", "other-review"));
720
721        // Suffix match
722        assert!(matches_glob("*-review", "code-review"));
723        assert!(matches_glob("*-review", "quick-review"));
724        assert!(!matches_glob("*-review", "code-fix"));
725
726        // Contains match
727        assert!(matches_glob("*code*", "my-code-review"));
728        assert!(!matches_glob("*code*", "my-review"));
729
730        // Multiple wildcards
731        assert!(matches_glob("*code*review*", "my-code-review-v2"));
732    }
733
734    #[test]
735    fn test_prompt_filter_builder() {
736        let filter = PromptFilter::new()
737            .with_domain(DomainScope::Project)
738            .with_tags(vec!["coding".to_string()])
739            .with_name_pattern("code-*")
740            .with_limit(10);
741
742        assert_eq!(filter.domain, Some(DomainScope::Project));
743        assert_eq!(filter.tags, vec!["coding"]);
744        assert_eq!(filter.name_pattern, Some("code-*".to_string()));
745        assert_eq!(filter.limit, Some(10));
746    }
747
748    #[test]
749    fn test_matches_filter_tags() {
750        let service = PromptService::default();
751
752        let template = PromptTemplate::new("test", "content")
753            .with_tags(vec!["coding".to_string(), "rust".to_string()]);
754
755        // Matches all tags
756        let filter = PromptFilter::new().with_tags(vec!["coding".to_string()]);
757        assert!(service.matches_filter(&template, &filter));
758
759        // Matches multiple tags
760        let filter = PromptFilter::new().with_tags(vec!["coding".to_string(), "rust".to_string()]);
761        assert!(service.matches_filter(&template, &filter));
762
763        // Doesn't match missing tag
764        let filter = PromptFilter::new().with_tags(vec!["python".to_string()]);
765        assert!(!service.matches_filter(&template, &filter));
766    }
767
768    #[test]
769    fn test_matches_filter_name_pattern() {
770        let service = PromptService::default();
771
772        let template = PromptTemplate::new("code-review", "content");
773
774        let filter = PromptFilter::new().with_name_pattern("code-*");
775        assert!(service.matches_filter(&template, &filter));
776
777        let filter = PromptFilter::new().with_name_pattern("*-review");
778        assert!(service.matches_filter(&template, &filter));
779
780        let filter = PromptFilter::new().with_name_pattern("other-*");
781        assert!(!service.matches_filter(&template, &filter));
782    }
783
784    #[test]
785    fn test_calculate_relevance() {
786        let service = PromptService::default();
787
788        let template = PromptTemplate::new("code-review", "Review code for issues")
789            .with_description("A helpful code review prompt")
790            .with_tags(vec!["coding".to_string(), "review".to_string()]);
791
792        // Exact name match should score highest
793        let exact_score = service.calculate_relevance(&template, "code-review");
794        let partial_score = service.calculate_relevance(&template, "code");
795        let desc_score = service.calculate_relevance(&template, "helpful");
796        let no_match_score = service.calculate_relevance(&template, "xyz123");
797
798        assert!(exact_score > partial_score);
799        assert!(partial_score > desc_score);
800        assert!(no_match_score == 0.0);
801    }
802
803    #[test]
804    fn test_save_options_default() {
805        let options = SaveOptions::new();
806        assert!(!options.skip_enrichment);
807        assert!(!options.dry_run);
808    }
809
810    #[test]
811    fn test_save_options_builder() {
812        let options = SaveOptions::new()
813            .with_skip_enrichment(true)
814            .with_dry_run(true);
815        assert!(options.skip_enrichment);
816        assert!(options.dry_run);
817    }
818
819    #[test]
820    fn test_save_options_default_trait() {
821        let options = SaveOptions::default();
822        assert!(!options.skip_enrichment);
823        assert!(!options.dry_run);
824    }
825}