1#![allow(clippy::print_stdout)]
7#![allow(clippy::needless_pass_by_value)]
9#![allow(clippy::option_if_let_else)]
11
12use crate::models::{PromptTemplate, PromptVariable, substitute_variables};
13use crate::services::{
14 EnrichmentStatus, PartialMetadata, PromptFilter, PromptFormat, PromptParser, PromptService,
15 SaveOptions, prompt_service_for_cwd,
16};
17use crate::storage::index::DomainScope;
18use std::collections::HashMap;
19use std::io::{self, Write};
20use std::path::PathBuf;
21
22pub struct PromptCommand;
24
25impl PromptCommand {
26 #[must_use]
28 pub const fn new() -> Self {
29 Self
30 }
31}
32
33impl Default for PromptCommand {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39#[derive(Debug, Clone, Copy, Default)]
41pub enum OutputFormat {
42 #[default]
44 Table,
45 Json,
47 Template,
49 Markdown,
51 Yaml,
53}
54
55impl OutputFormat {
56 #[must_use]
58 pub fn parse(s: &str) -> Self {
59 match s.to_lowercase().as_str() {
60 "json" => Self::Json,
61 "template" => Self::Template,
62 "markdown" | "md" => Self::Markdown,
63 "yaml" | "yml" => Self::Yaml,
64 _ => Self::Table,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default)]
73pub struct SavePromptArgs {
74 pub name: String,
76 pub content: Option<String>,
78 pub description: Option<String>,
80 pub tags: Option<String>,
82 pub domain: Option<String>,
84 pub from_file: Option<PathBuf>,
86 pub from_stdin: bool,
88 pub no_enrich: bool,
90 pub dry_run: bool,
92}
93
94impl SavePromptArgs {
95 #[must_use]
97 pub fn new(name: impl Into<String>) -> Self {
98 Self {
99 name: name.into(),
100 ..Default::default()
101 }
102 }
103
104 #[must_use]
106 pub fn with_content(mut self, content: impl Into<String>) -> Self {
107 self.content = Some(content.into());
108 self
109 }
110
111 #[must_use]
113 pub fn with_description(mut self, description: impl Into<String>) -> Self {
114 self.description = Some(description.into());
115 self
116 }
117
118 #[must_use]
120 pub fn with_tags(mut self, tags: impl Into<String>) -> Self {
121 self.tags = Some(tags.into());
122 self
123 }
124
125 #[must_use]
127 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
128 self.domain = Some(domain.into());
129 self
130 }
131
132 #[must_use]
134 pub fn with_file(mut self, path: PathBuf) -> Self {
135 self.from_file = Some(path);
136 self
137 }
138
139 #[must_use]
141 pub const fn with_stdin(mut self, from_stdin: bool) -> Self {
142 self.from_stdin = from_stdin;
143 self
144 }
145
146 #[must_use]
148 pub const fn with_no_enrich(mut self, no_enrich: bool) -> Self {
149 self.no_enrich = no_enrich;
150 self
151 }
152
153 #[must_use]
155 pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
156 self.dry_run = dry_run;
157 self
158 }
159}
160
161fn parse_domain_scope(s: Option<&str>) -> DomainScope {
163 match s.map(str::to_lowercase).as_deref() {
164 Some("user") => DomainScope::User,
165 Some("org") => DomainScope::Org,
166 _ => DomainScope::Project,
167 }
168}
169
170const fn domain_scope_to_display(scope: DomainScope) -> &'static str {
172 match scope {
173 DomainScope::Project => "project",
174 DomainScope::User => "user",
175 DomainScope::Org => "org",
176 }
177}
178
179fn create_prompt_service() -> Result<PromptService, Box<dyn std::error::Error>> {
184 prompt_service_for_cwd().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
185}
186
187pub fn cmd_prompt_save_with_args(args: SavePromptArgs) -> Result<(), Box<dyn std::error::Error>> {
197 let mut service = create_prompt_service()?;
198 let scope = parse_domain_scope(args.domain.as_deref());
199
200 let base_template = build_template_from_input(
202 args.name.clone(),
203 args.content,
204 args.from_file,
205 args.from_stdin,
206 )?;
207
208 let existing = build_partial_metadata(args.description, args.tags, &base_template);
210
211 let options = SaveOptions::new()
213 .with_skip_enrichment(args.no_enrich)
214 .with_dry_run(args.dry_run);
215
216 let result = service.save_with_enrichment::<crate::llm::OllamaClient>(
219 &args.name,
220 &base_template.content,
221 scope,
222 &options,
223 None, if existing.is_empty() {
225 None
226 } else {
227 Some(existing)
228 },
229 )?;
230
231 if args.dry_run {
233 println!("Dry run - template would be saved as:");
234 } else {
235 println!("Prompt saved:");
236 }
237 println!(" Name: {}", result.template.name);
238 if !args.dry_run {
239 println!(" ID: {}", result.id);
240 }
241 println!(" Domain: {}", domain_scope_to_display(scope));
242
243 match result.enrichment_status {
245 EnrichmentStatus::Full => println!(" Enrichment: LLM-enhanced"),
246 EnrichmentStatus::Fallback => println!(" Enrichment: fallback (LLM unavailable)"),
247 EnrichmentStatus::Skipped => println!(" Enrichment: skipped"),
248 }
249
250 if !result.template.description.is_empty() {
251 println!(" Description: {}", result.template.description);
252 }
253 if !result.template.tags.is_empty() {
254 println!(" Tags: {}", result.template.tags.join(", "));
255 }
256 if !result.template.variables.is_empty() {
257 println!(" Variables:");
258 for var in &result.template.variables {
259 let required = if var.required { "*" } else { "" };
260 let default = var
261 .default
262 .as_ref()
263 .map_or(String::new(), |d| format!(" = \"{d}\""));
264 println!(" - {}{required}{default}", var.name);
265 }
266 }
267
268 Ok(())
269}
270
271#[allow(clippy::too_many_arguments)]
289pub fn cmd_prompt_save(
290 name: String,
291 content: Option<String>,
292 description: Option<String>,
293 tags: Option<String>,
294 domain: Option<String>,
295 from_file: Option<PathBuf>,
296 from_stdin: bool,
297 no_enrich: bool,
298 dry_run: bool,
299) -> Result<(), Box<dyn std::error::Error>> {
300 let args = SavePromptArgs {
301 name,
302 content,
303 description,
304 tags,
305 domain,
306 from_file,
307 from_stdin,
308 no_enrich,
309 dry_run,
310 };
311 cmd_prompt_save_with_args(args)
312}
313
314fn build_partial_metadata(
316 description: Option<String>,
317 tags: Option<String>,
318 base_template: &PromptTemplate,
319) -> PartialMetadata {
320 let mut meta = PartialMetadata::new();
321
322 if let Some(desc) = description {
324 meta = meta.with_description(desc);
325 }
326
327 if let Some(tag_str) = tags {
329 let tag_list: Vec<String> = tag_str.split(',').map(|s| s.trim().to_string()).collect();
330 meta = meta.with_tags(tag_list);
331 }
332
333 if !base_template.variables.is_empty() {
335 meta = meta.with_variables(base_template.variables.clone());
336 }
337
338 meta
339}
340
341fn build_template_from_input(
343 name: String,
344 content: Option<String>,
345 from_file: Option<PathBuf>,
346 from_stdin: bool,
347) -> Result<PromptTemplate, Box<dyn std::error::Error>> {
348 if let Some(path) = from_file {
349 let mut template: PromptTemplate =
351 PromptParser::from_file(&path).map_err(|e| e.to_string())?;
352 template.name = name; Ok(template)
354 } else if from_stdin {
355 let mut template =
357 PromptParser::from_stdin(PromptFormat::Markdown, &name).map_err(|e| e.to_string())?;
358 template.name = name; Ok(template)
360 } else if let Some(content_str) = content {
361 Ok(PromptTemplate::new(name, content_str))
363 } else {
364 Err("Either content, --from-file, or --from-stdin is required".into())
365 }
366}
367
368fn format_variables_summary(variables: &[PromptVariable]) -> String {
370 variables
371 .iter()
372 .map(|v| {
373 if v.required {
374 format!("{{{{{}}}}}", v.name)
375 } else {
376 format!("{{{{{}}}}}?", v.name)
377 }
378 })
379 .collect::<Vec<_>>()
380 .join(", ")
381}
382
383pub fn cmd_prompt_list(
397 domain: Option<String>,
398 tags: Option<String>,
399 name_pattern: Option<String>,
400 format: Option<String>,
401 limit: Option<usize>,
402) -> Result<(), Box<dyn std::error::Error>> {
403 let mut service = create_prompt_service()?;
404
405 let mut filter = PromptFilter::default();
407 if let Some(tag_str) = tags {
408 filter = filter.with_tags(tag_str.split(',').map(|s| s.trim().to_string()).collect());
409 }
410 if let Some(pattern) = name_pattern {
411 filter = filter.with_name_pattern(&pattern);
412 }
413 if let Some(n) = limit {
414 filter = filter.with_limit(n);
415 }
416
417 let prompts = service.list(&filter)?;
418 let output_format = format
419 .as_deref()
420 .map_or(OutputFormat::Table, OutputFormat::parse);
421
422 match output_format {
423 OutputFormat::Json => {
424 let json = serde_json::to_string_pretty(&prompts)?;
425 println!("{json}");
426 },
427 _ => {
428 print_prompts_table(&prompts, domain.as_deref());
429 },
430 }
431
432 Ok(())
433}
434
435fn print_prompts_table(prompts: &[PromptTemplate], _domain_filter: Option<&str>) {
437 if prompts.is_empty() {
438 println!("No prompts found.");
439 return;
440 }
441
442 println!("{:<20} {:<40} {:<6} TAGS", "NAME", "DESCRIPTION", "USAGE");
443 println!("{}", "-".repeat(80));
444
445 for prompt in prompts {
446 let desc = if prompt.description.len() > 38 {
447 format!("{}...", &prompt.description[..35])
448 } else {
449 prompt.description.clone()
450 };
451 let tags = if prompt.tags.is_empty() {
452 String::new()
453 } else {
454 prompt.tags.join(", ")
455 };
456 println!(
457 "{:<20} {:<40} {:<6} {}",
458 prompt.name, desc, prompt.usage_count, tags
459 );
460 }
461
462 println!();
463 println!("Total: {} prompts", prompts.len());
464}
465
466pub fn cmd_prompt_get(
478 name: String,
479 domain: Option<String>,
480 format: Option<String>,
481) -> Result<(), Box<dyn std::error::Error>> {
482 let mut service = create_prompt_service()?;
483
484 let scope = domain.as_deref().map(|d| parse_domain_scope(Some(d)));
485 let prompt = service.get(&name, scope)?;
486
487 let Some(template) = prompt else {
488 return Err(format!("Prompt not found: {name}").into());
489 };
490
491 let output_format = format
492 .as_deref()
493 .map_or(OutputFormat::Template, OutputFormat::parse);
494
495 match output_format {
496 OutputFormat::Json => {
497 let json = serde_json::to_string_pretty(&template)?;
498 println!("{json}");
499 },
500 OutputFormat::Template | OutputFormat::Table => {
501 print_template_details(&template);
502 },
503 OutputFormat::Markdown => {
504 let md = PromptParser::serialize(&template, PromptFormat::Markdown)?;
505 println!("{md}");
506 },
507 OutputFormat::Yaml => {
508 let yaml = PromptParser::serialize(&template, PromptFormat::Yaml)?;
509 println!("{yaml}");
510 },
511 }
512
513 Ok(())
514}
515
516fn print_template_details(template: &PromptTemplate) {
518 println!("Name: {}", template.name);
519 if !template.description.is_empty() {
520 println!("Description: {}", template.description);
521 }
522 if !template.tags.is_empty() {
523 println!("Tags: {}", template.tags.join(", "));
524 }
525 if let Some(author) = &template.author {
526 println!("Author: {author}");
527 }
528 println!("Usage Count: {}", template.usage_count);
529 println!();
530
531 if !template.variables.is_empty() {
532 println!("Variables:");
533 for var in &template.variables {
534 let required = if var.required {
535 "(required)"
536 } else {
537 "(optional)"
538 };
539 let default = var
540 .default
541 .as_ref()
542 .map_or(String::new(), |d| format!(" [default: {d}]"));
543 let desc = var
544 .description
545 .as_ref()
546 .map_or(String::new(), |d| format!(" - {d}"));
547 println!(" {{{{{}}}}}{} {}{}", var.name, required, default, desc);
548 }
549 println!();
550 }
551
552 println!("Content:");
553 println!("--------");
554 println!("{}", template.content);
555}
556
557pub fn cmd_prompt_run(
570 name: String,
571 variables: Vec<String>,
572 domain: Option<String>,
573 interactive: bool,
574) -> Result<(), Box<dyn std::error::Error>> {
575 let mut service = create_prompt_service()?;
576
577 let scope = domain.as_deref().map(|d| parse_domain_scope(Some(d)));
578 let prompt = service.get(&name, scope)?;
579
580 let Some(template) = prompt else {
581 return Err(format!("Prompt not found: {name}").into());
582 };
583
584 let mut values: HashMap<String, String> = HashMap::new();
586 for var_str in &variables {
587 if let Some((key, value)) = var_str.split_once('=') {
588 values.insert(key.to_string(), value.to_string());
589 }
590 }
591
592 let missing = find_missing_variables(&template.variables, &values);
594
595 if !missing.is_empty() {
597 if interactive {
598 prompt_for_variables(&missing, &template.variables, &mut values)?;
599 } else {
600 let missing_names: Vec<_> = missing.iter().map(|s| format!("{{{{{s}}}}}")).collect();
601 return Err(format!(
602 "Missing required variables: {}. Use --interactive or provide with --var KEY=VALUE",
603 missing_names.join(", ")
604 )
605 .into());
606 }
607 }
608
609 let result = substitute_variables(&template.content, &values, &template.variables)?;
611
612 let actual_scope = scope.unwrap_or(DomainScope::Project);
614 let _ = service.increment_usage(&name, actual_scope);
615
616 println!("{result}");
618
619 Ok(())
620}
621
622fn find_missing_variables<'a>(
624 variables: &'a [PromptVariable],
625 values: &HashMap<String, String>,
626) -> Vec<&'a str> {
627 variables
628 .iter()
629 .filter(|v| v.required && v.default.is_none() && !values.contains_key(&v.name))
630 .map(|v| v.name.as_str())
631 .collect()
632}
633
634fn prompt_for_variables(
636 missing: &[&str],
637 variables: &[PromptVariable],
638 values: &mut HashMap<String, String>,
639) -> Result<(), Box<dyn std::error::Error>> {
640 let stdin = io::stdin();
641 let mut stdout = io::stdout();
642
643 for var_name in missing {
644 let var_def = variables.iter().find(|v| v.name == *var_name);
646
647 let prompt_text = match var_def.and_then(|v| v.description.as_ref()) {
648 Some(desc) => format!("{var_name} ({desc}): "),
649 None => format!("{var_name}: "),
650 };
651
652 write!(stdout, "{prompt_text}")?;
653 stdout.flush()?;
654
655 let mut input = String::new();
656 stdin.read_line(&mut input)?;
657 let trimmed = input.trim();
658
659 let value = if trimmed.is_empty() {
661 var_def.and_then(|v| v.default.clone()).unwrap_or_default()
662 } else {
663 trimmed.to_string()
664 };
665
666 values.insert((*var_name).to_string(), value);
667 }
668
669 Ok(())
670}
671
672pub fn cmd_prompt_delete(
684 name: String,
685 domain: String,
686 force: bool,
687) -> Result<(), Box<dyn std::error::Error>> {
688 let mut service = create_prompt_service()?;
689
690 let scope = parse_domain_scope(Some(&domain));
691
692 if !force {
694 print!("Delete prompt '{name}' from {domain}? [y/N]: ");
695 io::stdout().flush()?;
696
697 let mut input = String::new();
698 io::stdin().read_line(&mut input)?;
699
700 if !input.trim().eq_ignore_ascii_case("y") {
701 println!("Cancelled.");
702 return Ok(());
703 }
704 }
705
706 let deleted = service.delete(&name, scope)?;
707
708 if deleted {
709 println!("Prompt '{name}' deleted from {domain}.");
710 } else {
711 println!("Prompt '{name}' not found in {domain}.");
712 }
713
714 Ok(())
715}
716
717pub fn cmd_prompt_export(
730 name: String,
731 output: Option<PathBuf>,
732 format: Option<String>,
733 domain: Option<String>,
734) -> Result<(), Box<dyn std::error::Error>> {
735 let mut service = create_prompt_service()?;
736
737 let scope = domain.as_deref().map(|d| parse_domain_scope(Some(d)));
738 let prompt = service.get(&name, scope)?;
739
740 let Some(template) = prompt else {
741 return Err(format!("Prompt not found: {name}").into());
742 };
743
744 let export_format = determine_export_format(format.as_deref(), output.as_ref());
746
747 let content = match export_format {
748 OutputFormat::Yaml => PromptParser::serialize(&template, PromptFormat::Yaml)?,
749 OutputFormat::Json => serde_json::to_string_pretty(&template)?,
750 OutputFormat::Markdown | OutputFormat::Table | OutputFormat::Template => {
752 PromptParser::serialize(&template, PromptFormat::Markdown)?
753 },
754 };
755
756 if let Some(path) = output {
758 std::fs::write(&path, &content)?;
759 println!("Exported to: {}", path.display());
760 } else {
761 println!("{content}");
762 }
763
764 Ok(())
765}
766
767fn determine_export_format(format: Option<&str>, output: Option<&PathBuf>) -> OutputFormat {
769 if let Some(fmt) = format {
771 return OutputFormat::parse(fmt);
772 }
773
774 if let Some(ext) = output.and_then(|path| path.extension().and_then(|e| e.to_str())) {
776 return match ext.to_lowercase().as_str() {
777 "yaml" | "yml" => OutputFormat::Yaml,
778 "json" => OutputFormat::Json,
779 _ => OutputFormat::Markdown,
781 };
782 }
783
784 OutputFormat::Markdown
785}
786
787pub fn cmd_prompt_import(
800 source: String,
801 domain: String,
802 name: Option<String>,
803 no_validate: bool,
804) -> Result<(), Box<dyn std::error::Error>> {
805 let mut service = create_prompt_service()?;
806
807 let mut template = load_template_from_source(&source)?;
809
810 if let Some(override_name) = name {
812 template.name = override_name;
813 }
814
815 if !no_validate {
817 validate_template(&template)?;
818 }
819
820 let scope = parse_domain_scope(Some(&domain));
821 let id = service.save(&template, scope)?;
822
823 println!("Prompt imported:");
824 println!(" Name: {}", template.name);
825 println!(" ID: {id}");
826 println!(" Domain: {}", domain_scope_to_display(scope));
827 println!(" Source: {source}");
828 if !template.variables.is_empty() {
829 println!(
830 " Variables: {}",
831 format_variables_summary(&template.variables)
832 );
833 }
834 if !template.tags.is_empty() {
835 println!(" Tags: {}", template.tags.join(", "));
836 }
837
838 Ok(())
839}
840
841fn infer_format_from_path(source: &str) -> PromptFormat {
843 let path = std::path::Path::new(source);
844 path.extension()
845 .and_then(|ext| ext.to_str())
846 .map_or(PromptFormat::Markdown, |ext| {
847 match ext.to_lowercase().as_str() {
848 "json" => PromptFormat::Json,
849 "yaml" | "yml" => PromptFormat::Yaml,
850 _ => PromptFormat::Markdown,
851 }
852 })
853}
854
855fn load_template_from_source(source: &str) -> Result<PromptTemplate, Box<dyn std::error::Error>> {
857 if source.starts_with("http://") || source.starts_with("https://") {
858 let response = reqwest::blocking::get(source)?;
860 if !response.status().is_success() {
861 return Err(format!("Failed to fetch URL: HTTP {}", response.status()).into());
862 }
863
864 let content = response.text()?;
865
866 let format = infer_format_from_path(source);
868
869 PromptParser::parse(&content, format).map_err(|e| e.to_string().into())
870 } else {
871 let path = PathBuf::from(source);
873 PromptParser::from_file(&path).map_err(|e| e.to_string().into())
874 }
875}
876
877fn validate_template(template: &PromptTemplate) -> Result<(), Box<dyn std::error::Error>> {
879 if template.name.trim().is_empty() {
881 return Err("Template name cannot be empty".into());
882 }
883
884 if template.content.trim().is_empty() {
886 return Err("Template content cannot be empty".into());
887 }
888
889 for var in &template.variables {
891 if var.name.trim().is_empty() {
892 return Err("Variable name cannot be empty".into());
893 }
894 if var.name.starts_with("subcog_")
895 || var.name.starts_with("system_")
896 || var.name.starts_with("__")
897 {
898 return Err(format!(
899 "Variable name '{}' uses reserved prefix (subcog_, system_, __)",
900 var.name
901 )
902 .into());
903 }
904 }
905
906 Ok(())
907}
908
909pub fn cmd_prompt_share(
923 name: String,
924 output: Option<PathBuf>,
925 format: String,
926 domain: Option<String>,
927 include_stats: bool,
928) -> Result<(), Box<dyn std::error::Error>> {
929 let mut service = create_prompt_service()?;
930
931 let scope = domain.as_deref().map(|d| parse_domain_scope(Some(d)));
932 let prompt = service.get(&name, scope)?;
933
934 let Some(template) = prompt else {
935 return Err(format!("Prompt not found: {name}").into());
936 };
937
938 let share_content = build_share_content(&template, include_stats, &format)?;
940
941 if let Some(path) = output {
943 std::fs::write(&path, &share_content)?;
944 println!("Shared to: {}", path.display());
945 println!(" Name: {}", template.name);
946 println!(" Format: {format}");
947 if include_stats {
948 println!(" Usage count: {}", template.usage_count);
949 }
950 } else {
951 println!("{share_content}");
952 }
953
954 Ok(())
955}
956
957fn format_timestamp(ts: u64) -> String {
959 chrono::DateTime::from_timestamp(i64::try_from(ts).unwrap_or(0), 0).map_or_else(
960 || "unknown".to_string(),
961 |dt| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
962 )
963}
964
965fn format_timestamp_short(ts: u64) -> String {
967 chrono::DateTime::from_timestamp(i64::try_from(ts).unwrap_or(0), 0).map_or_else(
968 || "unknown".to_string(),
969 |dt| dt.format("%Y-%m-%d").to_string(),
970 )
971}
972
973fn build_share_content(
975 template: &PromptTemplate,
976 include_stats: bool,
977 format: &str,
978) -> Result<String, Box<dyn std::error::Error>> {
979 let output_format = OutputFormat::parse(format);
980
981 match output_format {
982 OutputFormat::Yaml => {
983 let yaml = PromptParser::serialize(template, PromptFormat::Yaml)?;
985 if include_stats {
986 let created = format_timestamp(template.created_at);
987 let updated = format_timestamp(template.updated_at);
988 let stats_header = format!(
989 "# Subcog Prompt Share\n# Usage count: {}\n# Created: {}\n# Last used: {}\n\n",
990 template.usage_count, created, updated,
991 );
992 Ok(format!("{stats_header}{yaml}"))
993 } else {
994 Ok(yaml)
995 }
996 },
997 OutputFormat::Json => {
998 if include_stats {
999 let mut json_value: serde_json::Value = serde_json::to_value(template)?;
1001 if let Some(obj) = json_value.as_object_mut() {
1002 obj.insert(
1003 "_share_metadata".to_string(),
1004 serde_json::json!({
1005 "exported_at": chrono::Utc::now().to_rfc3339(),
1006 "usage_count": template.usage_count,
1007 }),
1008 );
1009 }
1010 Ok(serde_json::to_string_pretty(&json_value)?)
1011 } else {
1012 Ok(serde_json::to_string_pretty(template)?)
1013 }
1014 },
1015 OutputFormat::Markdown | OutputFormat::Table | OutputFormat::Template => {
1016 let md = PromptParser::serialize(template, PromptFormat::Markdown)?;
1017 if include_stats {
1018 let created = format_timestamp_short(template.created_at);
1019 let updated = format_timestamp_short(template.updated_at);
1020 let stats_footer = format!(
1021 "\n---\n\n*Usage count: {} | Created: {} | Last used: {}*",
1022 template.usage_count, created, updated,
1023 );
1024 Ok(format!("{md}{stats_footer}"))
1025 } else {
1026 Ok(md)
1027 }
1028 },
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
1036 #[test]
1037 fn test_parse_domain_scope() {
1038 assert!(matches!(parse_domain_scope(None), DomainScope::Project));
1039 assert!(matches!(
1040 parse_domain_scope(Some("project")),
1041 DomainScope::Project
1042 ));
1043 assert!(matches!(
1044 parse_domain_scope(Some("user")),
1045 DomainScope::User
1046 ));
1047 assert!(matches!(
1048 parse_domain_scope(Some("User")),
1049 DomainScope::User
1050 ));
1051 assert!(matches!(parse_domain_scope(Some("org")), DomainScope::Org));
1052 assert!(matches!(parse_domain_scope(Some("ORG")), DomainScope::Org));
1053 assert!(matches!(
1054 parse_domain_scope(Some("invalid")),
1055 DomainScope::Project
1056 ));
1057 }
1058
1059 #[test]
1060 fn test_output_format_from_str() {
1061 assert!(matches!(OutputFormat::parse("json"), OutputFormat::Json));
1062 assert!(matches!(OutputFormat::parse("JSON"), OutputFormat::Json));
1063 assert!(matches!(
1064 OutputFormat::parse("template"),
1065 OutputFormat::Template
1066 ));
1067 assert!(matches!(
1068 OutputFormat::parse("markdown"),
1069 OutputFormat::Markdown
1070 ));
1071 assert!(matches!(OutputFormat::parse("md"), OutputFormat::Markdown));
1072 assert!(matches!(OutputFormat::parse("yaml"), OutputFormat::Yaml));
1073 assert!(matches!(OutputFormat::parse("yml"), OutputFormat::Yaml));
1074 assert!(matches!(
1075 OutputFormat::parse("invalid"),
1076 OutputFormat::Table
1077 ));
1078 }
1079
1080 #[test]
1081 fn test_format_variables_summary() {
1082 let vars = vec![
1083 PromptVariable {
1084 name: "required_var".to_string(),
1085 description: None,
1086 default: None,
1087 required: true,
1088 },
1089 PromptVariable {
1090 name: "optional_var".to_string(),
1091 description: None,
1092 default: Some("default".to_string()),
1093 required: false,
1094 },
1095 ];
1096
1097 let summary = format_variables_summary(&vars);
1098 assert!(summary.contains("{{required_var}}"));
1099 assert!(summary.contains("{{optional_var}}?"));
1100 }
1101
1102 #[test]
1103 fn test_find_missing_variables() {
1104 let vars = vec![
1105 PromptVariable {
1106 name: "required".to_string(),
1107 description: None,
1108 default: None,
1109 required: true,
1110 },
1111 PromptVariable {
1112 name: "with_default".to_string(),
1113 description: None,
1114 default: Some("default".to_string()),
1115 required: true,
1116 },
1117 PromptVariable {
1118 name: "optional".to_string(),
1119 description: None,
1120 default: None,
1121 required: false,
1122 },
1123 ];
1124
1125 let mut values = HashMap::new();
1126 let missing = find_missing_variables(&vars, &values);
1127 assert_eq!(missing, vec!["required"]);
1128
1129 values.insert("required".to_string(), "value".to_string());
1130 let missing = find_missing_variables(&vars, &values);
1131 assert!(missing.is_empty());
1132 }
1133
1134 #[test]
1135 fn test_determine_export_format() {
1136 assert!(matches!(
1138 determine_export_format(Some("json"), None),
1139 OutputFormat::Json
1140 ));
1141
1142 assert!(matches!(
1144 determine_export_format(None, Some(&PathBuf::from("test.yaml"))),
1145 OutputFormat::Yaml
1146 ));
1147 assert!(matches!(
1148 determine_export_format(None, Some(&PathBuf::from("test.json"))),
1149 OutputFormat::Json
1150 ));
1151 assert!(matches!(
1152 determine_export_format(None, Some(&PathBuf::from("test.md"))),
1153 OutputFormat::Markdown
1154 ));
1155
1156 assert!(matches!(
1158 determine_export_format(None, None),
1159 OutputFormat::Markdown
1160 ));
1161 }
1162
1163 #[test]
1164 fn test_domain_scope_to_display() {
1165 assert_eq!(domain_scope_to_display(DomainScope::Project), "project");
1166 assert_eq!(domain_scope_to_display(DomainScope::User), "user");
1167 assert_eq!(domain_scope_to_display(DomainScope::Org), "org");
1168 }
1169
1170 #[test]
1171 fn test_format_variables_summary_empty() {
1172 let vars: Vec<PromptVariable> = vec![];
1173 let summary = format_variables_summary(&vars);
1174 assert!(summary.is_empty());
1175 }
1176
1177 #[test]
1178 fn test_determine_export_format_explicit_overrides_extension() {
1179 assert!(matches!(
1181 determine_export_format(Some("yaml"), Some(&PathBuf::from("test.json"))),
1182 OutputFormat::Yaml
1183 ));
1184 }
1185
1186 #[test]
1187 fn test_output_format_default() {
1188 let default_format = OutputFormat::default();
1189 assert!(matches!(default_format, OutputFormat::Table));
1190 }
1191
1192 #[test]
1193 fn test_infer_format_from_path_json() {
1194 assert!(matches!(
1195 infer_format_from_path("prompt.json"),
1196 PromptFormat::Json
1197 ));
1198 assert!(matches!(
1199 infer_format_from_path("/path/to/prompt.JSON"),
1200 PromptFormat::Json
1201 ));
1202 }
1203
1204 #[test]
1205 fn test_infer_format_from_path_yaml() {
1206 assert!(matches!(
1207 infer_format_from_path("prompt.yaml"),
1208 PromptFormat::Yaml
1209 ));
1210 assert!(matches!(
1211 infer_format_from_path("prompt.yml"),
1212 PromptFormat::Yaml
1213 ));
1214 assert!(matches!(
1215 infer_format_from_path("https://example.com/prompt.YAML"),
1216 PromptFormat::Yaml
1217 ));
1218 }
1219
1220 #[test]
1221 fn test_infer_format_from_path_markdown() {
1222 assert!(matches!(
1223 infer_format_from_path("prompt.md"),
1224 PromptFormat::Markdown
1225 ));
1226 assert!(matches!(
1227 infer_format_from_path("prompt.txt"),
1228 PromptFormat::Markdown
1229 ));
1230 assert!(matches!(
1231 infer_format_from_path("prompt"),
1232 PromptFormat::Markdown
1233 ));
1234 }
1235
1236 #[test]
1237 fn test_validate_template_valid() {
1238 let template = PromptTemplate {
1239 name: "test-prompt".to_string(),
1240 content: "Test content".to_string(),
1241 ..Default::default()
1242 };
1243 assert!(validate_template(&template).is_ok());
1244 }
1245
1246 #[test]
1247 fn test_validate_template_empty_name() {
1248 let template = PromptTemplate {
1249 name: String::new(),
1250 content: "Test content".to_string(),
1251 ..Default::default()
1252 };
1253 assert!(validate_template(&template).is_err());
1254 }
1255
1256 #[test]
1257 fn test_validate_template_empty_content() {
1258 let template = PromptTemplate {
1259 name: "test".to_string(),
1260 content: " ".to_string(),
1261 ..Default::default()
1262 };
1263 assert!(validate_template(&template).is_err());
1264 }
1265
1266 #[test]
1267 fn test_validate_template_reserved_variable_prefix() {
1268 let template = PromptTemplate {
1269 name: "test".to_string(),
1270 content: "Test {{subcog_internal}}".to_string(),
1271 variables: vec![PromptVariable {
1272 name: "subcog_internal".to_string(),
1273 description: None,
1274 default: None,
1275 required: false,
1276 }],
1277 ..Default::default()
1278 };
1279 assert!(validate_template(&template).is_err());
1280 }
1281
1282 #[test]
1283 fn test_format_timestamp() {
1284 let result = format_timestamp(0);
1286 assert!(result.contains("1970-01-01"));
1287
1288 let result = format_timestamp(1_704_067_200);
1290 assert!(result.contains("2024-01-01"));
1291 }
1292
1293 #[test]
1294 fn test_format_timestamp_short() {
1295 let result = format_timestamp_short(0);
1296 assert_eq!(result, "1970-01-01");
1297
1298 let result = format_timestamp_short(1_704_067_200);
1299 assert_eq!(result, "2024-01-01");
1300 }
1301}