1use 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
35const SCORE_EXACT_NAME_MATCH: f32 = 10.0;
38const SCORE_PARTIAL_NAME_MATCH: f32 = 5.0;
40const SCORE_DESCRIPTION_MATCH: f32 = 3.0;
42const SCORE_CONTENT_MATCH: f32 = 1.0;
44const SCORE_TAG_MATCH: f32 = 2.0;
46const USAGE_BOOST_DIVISOR: f32 = 100.0;
48const USAGE_BOOST_MAX: f32 = 0.5;
50
51#[derive(Debug, Clone, Default)]
53pub struct PromptFilter {
54 pub domain: Option<DomainScope>,
56 pub tags: Vec<String>,
58 pub name_pattern: Option<String>,
60 pub limit: Option<usize>,
62}
63
64impl PromptFilter {
65 #[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#[derive(Debug, Clone, Default)]
79pub struct SaveOptions {
80 pub skip_enrichment: bool,
82 pub dry_run: bool,
84}
85
86impl SaveOptions {
87 #[must_use]
89 pub const fn new() -> Self {
90 Self {
91 skip_enrichment: false,
92 dry_run: false,
93 }
94 }
95
96 #[must_use]
98 pub const fn with_skip_enrichment(mut self, skip: bool) -> Self {
99 self.skip_enrichment = skip;
100 self
101 }
102
103 #[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#[derive(Debug, Clone)]
113pub struct SaveResult {
114 pub template: PromptTemplate,
116 pub id: String,
118 pub enrichment_status: EnrichmentStatus,
120}
121
122impl PromptFilter {
123 #[must_use]
125 pub const fn with_domain(mut self, domain: DomainScope) -> Self {
126 self.domain = Some(domain);
127 self
128 }
129
130 #[must_use]
132 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
133 self.tags = tags;
134 self
135 }
136
137 #[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 #[must_use]
146 pub const fn with_limit(mut self, limit: usize) -> Self {
147 self.limit = Some(limit);
148 self
149 }
150}
151
152pub struct PromptService {
156 config: Config,
158 subcog_config: Option<SubcogConfig>,
160 storage_cache: HashMap<DomainScope, Arc<dyn PromptStorage>>,
162}
163
164impl PromptService {
165 #[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 #[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 #[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 pub fn set_repo_path(&mut self, path: impl Into<PathBuf>) {
196 self.config.repo_path = Some(path.into());
197 self.storage_cache.clear();
199 }
200
201 fn get_storage(&mut self, domain: DomainScope) -> Result<Arc<dyn PromptStorage>> {
203 if let Some(storage) = self.storage_cache.get(&domain) {
205 return Ok(Arc::clone(storage));
206 }
207
208 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 self.storage_cache.insert(domain, Arc::clone(&storage));
218
219 Ok(storage)
220 }
221
222 pub fn save(&mut self, template: &PromptTemplate, domain: DomainScope) -> Result<String> {
252 validate_prompt_name(&template.name)?;
254
255 let storage = self.get_storage(domain)?;
257
258 storage.save(template)
260 }
261
262 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_prompt_name(name)?;
296
297 let extracted = crate::models::extract_variables(content);
299 let variable_names: Vec<String> = extracted.iter().map(|v| v.name.clone()).collect();
300
301 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 let enrichment = match (options.skip_enrichment, llm) {
312 (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 (true, _) | (false, None) => apply_fallback(&variable_names, existing.as_ref()),
321 };
322
323 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 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 pub fn get(
365 &mut self,
366 name: &str,
367 domain: Option<DomainScope>,
368 ) -> Result<Option<PromptTemplate>> {
369 let scopes = match domain {
371 Some(scope) => vec![scope],
372 None => vec![DomainScope::Project, DomainScope::User],
373 };
374
375 for scope in scopes {
376 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 pub fn list(&mut self, filter: &PromptFilter) -> Result<Vec<PromptTemplate>> {
404 let mut results = Vec::new();
405
406 let scopes = match filter.domain {
408 Some(scope) => vec![scope],
409 None => vec![DomainScope::Project, DomainScope::User],
410 };
411
412 for scope in scopes {
414 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 results.extend(
426 prompts
427 .into_iter()
428 .filter(|t| self.matches_filter(t, filter)),
429 );
430 }
431
432 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 if let Some(limit) = filter.limit {
441 results.truncate(limit);
442 }
443
444 Ok(results)
445 }
446
447 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 pub fn search(&mut self, query: &str, limit: usize) -> Result<Vec<PromptTemplate>> {
481 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 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
496
497 Ok(scored.into_iter().take(limit).map(|(t, _)| t).collect())
499 }
500
501 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 fn matches_filter(&self, template: &PromptTemplate, filter: &PromptFilter) -> bool {
519 for tag in &filter.tags {
521 if !template.tags.iter().any(|t| t == tag) {
522 return false;
523 }
524 }
525
526 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 fn calculate_relevance(&self, template: &PromptTemplate, query: &str) -> f32 {
538 let mut score = 0.0f32;
539
540 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 if template.description.to_lowercase().contains(query) {
549 score += SCORE_DESCRIPTION_MATCH;
550 }
551
552 if template.content.to_lowercase().contains(query) {
554 score += SCORE_CONTENT_MATCH;
555 }
556
557 for tag in &template.tags {
559 if tag.to_lowercase().contains(query) {
560 score += SCORE_TAG_MATCH;
561 }
562 }
563
564 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
577pub 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 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 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 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
631fn 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 if parts.is_empty() {
641 return true;
642 }
643
644 if !parts[0].is_empty() && !text.starts_with(parts[0]) {
646 return false;
647 }
648
649 let last = parts.last().unwrap_or(&"");
651 if !last.is_empty() && !text.ends_with(last) {
652 return false;
653 }
654
655 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 assert!(validate_prompt_name("").is_err());
687
688 assert!(validate_prompt_name("1invalid").is_err());
690
691 assert!(validate_prompt_name("-invalid").is_err());
693
694 assert!(validate_prompt_name("Invalid").is_err());
696
697 assert!(validate_prompt_name("invalid_name").is_err());
699
700 assert!(validate_prompt_name("invalid-").is_err());
702
703 assert!(validate_prompt_name("invalid--name").is_err());
705
706 assert!(validate_prompt_name("invalid name").is_err());
708 }
709
710 #[test]
711 fn test_matches_glob() {
712 assert!(matches_glob("code-review", "code-review"));
714 assert!(!matches_glob("code-review", "other"));
715
716 assert!(matches_glob("code-*", "code-review"));
718 assert!(matches_glob("code-*", "code-fix"));
719 assert!(!matches_glob("code-*", "other-review"));
720
721 assert!(matches_glob("*-review", "code-review"));
723 assert!(matches_glob("*-review", "quick-review"));
724 assert!(!matches_glob("*-review", "code-fix"));
725
726 assert!(matches_glob("*code*", "my-code-review"));
728 assert!(!matches_glob("*code*", "my-review"));
729
730 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 let filter = PromptFilter::new().with_tags(vec!["coding".to_string()]);
757 assert!(service.matches_filter(&template, &filter));
758
759 let filter = PromptFilter::new().with_tags(vec!["coding".to_string(), "rust".to_string()]);
761 assert!(service.matches_filter(&template, &filter));
762
763 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 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}