Skip to main content

subcog/cli/
prompt.rs

1//! Prompt CLI command.
2//!
3//! Provides subcommands for managing user-defined prompt templates.
4
5// CLI commands are allowed to use println! for output
6#![allow(clippy::print_stdout)]
7// CLI commands take owned strings from clap parsing
8#![allow(clippy::needless_pass_by_value)]
9// The if-let-else pattern is clearer for nested conditionals
10#![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
22/// Prompt command handler.
23pub struct PromptCommand;
24
25impl PromptCommand {
26    /// Creates a new prompt command.
27    #[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/// Output format for prompt commands.
40#[derive(Debug, Clone, Copy, Default)]
41pub enum OutputFormat {
42    /// Table format (default for list).
43    #[default]
44    Table,
45    /// JSON format.
46    Json,
47    /// Template format (for get).
48    Template,
49    /// Markdown format (for export).
50    Markdown,
51    /// YAML format (for export).
52    Yaml,
53}
54
55impl OutputFormat {
56    /// Parses output format from string.
57    #[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/// Arguments for the `prompt save` command.
70///
71/// Encapsulates all parameters to avoid function with too many arguments.
72#[derive(Debug, Clone, Default)]
73pub struct SavePromptArgs {
74    /// Prompt name (kebab-case).
75    pub name: String,
76    /// Optional inline content.
77    pub content: Option<String>,
78    /// Optional description.
79    pub description: Option<String>,
80    /// Optional comma-separated tags.
81    pub tags: Option<String>,
82    /// Optional domain scope.
83    pub domain: Option<String>,
84    /// Optional file path to load from.
85    pub from_file: Option<PathBuf>,
86    /// Whether to read from stdin.
87    pub from_stdin: bool,
88    /// Skip LLM-powered enrichment.
89    pub no_enrich: bool,
90    /// Show enriched template without saving.
91    pub dry_run: bool,
92}
93
94impl SavePromptArgs {
95    /// Creates new save arguments with a name.
96    #[must_use]
97    pub fn new(name: impl Into<String>) -> Self {
98        Self {
99            name: name.into(),
100            ..Default::default()
101        }
102    }
103
104    /// Sets the content.
105    #[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    /// Sets the description.
112    #[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    /// Sets the tags.
119    #[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    /// Sets the domain scope.
126    #[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    /// Sets the file path to load from.
133    #[must_use]
134    pub fn with_file(mut self, path: PathBuf) -> Self {
135        self.from_file = Some(path);
136        self
137    }
138
139    /// Sets whether to read from stdin.
140    #[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    /// Sets whether to skip enrichment.
147    #[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    /// Sets whether this is a dry run.
154    #[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
161/// Parses domain scope from string.
162fn 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
170/// Converts domain scope to display string.
171const 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
179/// Creates a [`PromptService`] with full config loaded.
180///
181/// Delegates to the canonical factory function in the services module to avoid
182/// layer violations (CLI layer should not directly construct services).
183fn 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
187/// Executes the `prompt save` subcommand.
188///
189/// # Arguments
190///
191/// * `args` - Save command arguments.
192///
193/// # Errors
194///
195/// Returns an error if saving fails.
196pub 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    // Build template from input source to get content
201    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    // Build partial metadata from user-provided values
209    let existing = build_partial_metadata(args.description, args.tags, &base_template);
210
211    // Configure save options
212    let options = SaveOptions::new()
213        .with_skip_enrichment(args.no_enrich)
214        .with_dry_run(args.dry_run);
215
216    // Use save_with_enrichment (no LLM provider for now - fallback mode)
217    // Future: Add LLM provider integration when API keys are available
218    let result = service.save_with_enrichment::<crate::llm::OllamaClient>(
219        &args.name,
220        &base_template.content,
221        scope,
222        &options,
223        None, // No LLM provider - uses fallback
224        if existing.is_empty() {
225            None
226        } else {
227            Some(existing)
228        },
229    )?;
230
231    // Display output
232    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    // Show enrichment status
244    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/// Executes the `prompt save` subcommand (legacy interface).
272///
273/// # Arguments
274///
275/// * `name` - Prompt name (kebab-case).
276/// * `content` - Optional inline content.
277/// * `description` - Optional description.
278/// * `tags` - Optional comma-separated tags.
279/// * `domain` - Optional domain scope.
280/// * `from_file` - Optional file path to load from.
281/// * `from_stdin` - Whether to read from stdin.
282/// * `no_enrich` - Skip LLM-powered enrichment.
283/// * `dry_run` - Show enriched template without saving.
284///
285/// # Errors
286///
287/// Returns an error if saving fails.
288#[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
314/// Builds partial metadata from user-provided values.
315fn 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    // User-provided description
323    if let Some(desc) = description {
324        meta = meta.with_description(desc);
325    }
326
327    // User-provided tags
328    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    // Variables from base template (if loaded from file with existing metadata)
334    if !base_template.variables.is_empty() {
335        meta = meta.with_variables(base_template.variables.clone());
336    }
337
338    meta
339}
340
341/// Builds a template from the various input sources.
342fn 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        // Parse from file, then override name with CLI argument
350        let mut template: PromptTemplate =
351            PromptParser::from_file(&path).map_err(|e| e.to_string())?;
352        template.name = name; // CLI --name always takes precedence
353        Ok(template)
354    } else if from_stdin {
355        // Parse from stdin, then override name with CLI argument
356        let mut template =
357            PromptParser::from_stdin(PromptFormat::Markdown, &name).map_err(|e| e.to_string())?;
358        template.name = name; // CLI --name always takes precedence
359        Ok(template)
360    } else if let Some(content_str) = content {
361        // Build from inline content
362        Ok(PromptTemplate::new(name, content_str))
363    } else {
364        Err("Either content, --from-file, or --from-stdin is required".into())
365    }
366}
367
368/// Formats a summary of variables for display.
369fn 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
383/// Executes the `prompt list` subcommand.
384///
385/// # Arguments
386///
387/// * `domain` - Optional domain scope filter.
388/// * `tags` - Optional comma-separated tags filter.
389/// * `name_pattern` - Optional name pattern (glob).
390/// * `format` - Output format.
391/// * `limit` - Maximum number of results.
392///
393/// # Errors
394///
395/// Returns an error if listing fails.
396pub 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    // Build filter
406    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
435/// Prints prompts in table format.
436fn 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
466/// Executes the `prompt get` subcommand.
467///
468/// # Arguments
469///
470/// * `name` - Prompt name to retrieve.
471/// * `domain` - Optional domain scope.
472/// * `format` - Output format.
473///
474/// # Errors
475///
476/// Returns an error if the prompt is not found.
477pub 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
516/// Prints template details in human-readable format.
517fn 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
557/// Executes the `prompt run` subcommand.
558///
559/// # Arguments
560///
561/// * `name` - Prompt name to run.
562/// * `variables` - Variable values as KEY=VALUE pairs.
563/// * `domain` - Optional domain scope.
564/// * `interactive` - Whether to prompt for missing variables.
565///
566/// # Errors
567///
568/// Returns an error if the prompt is not found or variables are missing.
569pub 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    // Parse provided variables
585    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    // Find missing required variables
593    let missing = find_missing_variables(&template.variables, &values);
594
595    // If interactive, prompt for missing values
596    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    // Substitute variables
610    let result = substitute_variables(&template.content, &values, &template.variables)?;
611
612    // Increment usage count
613    let actual_scope = scope.unwrap_or(DomainScope::Project);
614    let _ = service.increment_usage(&name, actual_scope);
615
616    // Output the result
617    println!("{result}");
618
619    Ok(())
620}
621
622/// Finds missing required variables.
623fn 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
634/// Prompts interactively for variable values.
635fn 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        // Find variable definition for description
645        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        // Use default if available and input is empty
660        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
672/// Executes the `prompt delete` subcommand.
673///
674/// # Arguments
675///
676/// * `name` - Prompt name to delete.
677/// * `domain` - Domain scope (required).
678/// * `force` - Skip confirmation.
679///
680/// # Errors
681///
682/// Returns an error if deletion fails.
683pub 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    // Confirm deletion unless --force
693    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
717/// Executes the `prompt export` subcommand.
718///
719/// # Arguments
720///
721/// * `name` - Prompt name to export.
722/// * `output` - Optional output file path.
723/// * `format` - Export format.
724/// * `domain` - Optional domain scope.
725///
726/// # Errors
727///
728/// Returns an error if export fails.
729pub 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    // Determine format from output path or explicit format
745    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        // Markdown is the default for Table, Template, and explicit Markdown
751        OutputFormat::Markdown | OutputFormat::Table | OutputFormat::Template => {
752            PromptParser::serialize(&template, PromptFormat::Markdown)?
753        },
754    };
755
756    // Write to file or stdout
757    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
767/// Determines export format from explicit format or file extension.
768fn determine_export_format(format: Option<&str>, output: Option<&PathBuf>) -> OutputFormat {
769    // Explicit format takes precedence
770    if let Some(fmt) = format {
771        return OutputFormat::parse(fmt);
772    }
773
774    // Infer from output file extension
775    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            // Default to Markdown for .md, .markdown, and unknown extensions
780            _ => OutputFormat::Markdown,
781        };
782    }
783
784    OutputFormat::Markdown
785}
786
787/// Executes the `prompt import` subcommand.
788///
789/// # Arguments
790///
791/// * `source` - Source file path or URL.
792/// * `domain` - Target domain scope.
793/// * `name` - Optional name override.
794/// * `no_validate` - Skip validation.
795///
796/// # Errors
797///
798/// Returns an error if import fails.
799pub 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    // Load template from source
808    let mut template = load_template_from_source(&source)?;
809
810    // Override name if provided
811    if let Some(override_name) = name {
812        template.name = override_name;
813    }
814
815    // Validate unless skipped
816    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
841/// Infers the prompt format from a file path or URL extension.
842fn 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
855/// Loads a template from a file path or URL.
856fn load_template_from_source(source: &str) -> Result<PromptTemplate, Box<dyn std::error::Error>> {
857    if source.starts_with("http://") || source.starts_with("https://") {
858        // URL source - fetch and parse
859        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        // Determine format from URL extension
867        let format = infer_format_from_path(source);
868
869        PromptParser::parse(&content, format).map_err(|e| e.to_string().into())
870    } else {
871        // File source
872        let path = PathBuf::from(source);
873        PromptParser::from_file(&path).map_err(|e| e.to_string().into())
874    }
875}
876
877/// Validates a template for required fields and variable syntax.
878fn validate_template(template: &PromptTemplate) -> Result<(), Box<dyn std::error::Error>> {
879    // Check name is not empty
880    if template.name.trim().is_empty() {
881        return Err("Template name cannot be empty".into());
882    }
883
884    // Check content is not empty
885    if template.content.trim().is_empty() {
886        return Err("Template content cannot be empty".into());
887    }
888
889    // Validate variable names
890    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
909/// Executes the `prompt share` subcommand.
910///
911/// # Arguments
912///
913/// * `name` - Prompt name to share.
914/// * `output` - Optional output file path.
915/// * `format` - Export format.
916/// * `domain` - Optional domain scope to search.
917/// * `include_stats` - Include usage statistics.
918///
919/// # Errors
920///
921/// Returns an error if sharing fails.
922pub 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    // Build shareable content with metadata
939    let share_content = build_share_content(&template, include_stats, &format)?;
940
941    // Write to file or stdout
942    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
957/// Formats a Unix timestamp as a full datetime string.
958fn 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
965/// Formats a Unix timestamp as a short date string.
966fn 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
973/// Builds shareable content with full metadata.
974fn 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            // Include stats as metadata comments
984            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                // Include stats in JSON output
1000                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        // Explicit format takes precedence
1137        assert!(matches!(
1138            determine_export_format(Some("json"), None),
1139            OutputFormat::Json
1140        ));
1141
1142        // Infer from file extension
1143        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        // Default to markdown
1157        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        // Explicit format should override file extension
1180        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        // Test with epoch timestamp
1285        let result = format_timestamp(0);
1286        assert!(result.contains("1970-01-01"));
1287
1288        // Test with a known timestamp (2024-01-01 00:00:00 UTC)
1289        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}