Skip to main content

systemprompt_cli/commands/web/templates/
create.rs

1use anyhow::{Context, Result, anyhow};
2use clap::Args;
3use std::fs;
4use std::io::{self, Read};
5use std::path::Path;
6
7use crate::CliConfig;
8use crate::interactive::{Prompter, resolve_required};
9use crate::shared::CommandOutput;
10use systemprompt_logging::CliService;
11
12use super::super::paths::WebPaths;
13use super::super::types::{TemplateCreateOutput, TemplateEntry, TemplatesConfig};
14
15#[derive(Debug, Args)]
16pub struct CreateArgs {
17    #[arg(long, help = "Template name")]
18    pub name: Option<String>,
19
20    #[arg(long, help = "Content types to link (comma-separated)")]
21    pub content_types: Option<String>,
22
23    #[arg(long, help = "HTML content (use '-' to read from stdin)")]
24    pub content: Option<String>,
25}
26
27pub(super) fn execute(
28    args: CreateArgs,
29    prompter: &dyn Prompter,
30    config: &CliConfig,
31) -> Result<CommandOutput> {
32    execute_in_dir(args, prompter, config, &WebPaths::resolve()?.templates)
33}
34
35pub fn execute_in_dir(
36    args: CreateArgs,
37    prompter: &dyn Prompter,
38    config: &CliConfig,
39    templates_dir: &Path,
40) -> Result<CommandOutput> {
41    let templates_yaml_path = templates_dir.join("templates.yaml");
42
43    let mut templates_config = load_templates_config(&templates_yaml_path)?;
44
45    let name = resolve_required(args.name, "name", config, || prompt_name(prompter))?;
46
47    if templates_config.templates.contains_key(&name) {
48        return Err(anyhow!("Template '{}' already exists", name));
49    }
50
51    let content_types = resolve_content_types(args.content_types, prompter, config)?;
52
53    let html_file_path = templates_dir.join(format!("{}.html", name));
54
55    let html_written = if let Some(content_source) = &args.content {
56        let html_content = read_html_content(content_source)?;
57        fs::write(&html_file_path, html_content)
58            .with_context(|| format!("Failed to write HTML file: {}", html_file_path.display()))?;
59        true
60    } else {
61        false
62    };
63
64    templates_config
65        .templates
66        .insert(name.clone(), TemplateEntry { content_types });
67
68    save_templates_config(&templates_yaml_path, &templates_config)?;
69
70    let message = if html_written {
71        format!(
72            "Template '{}' created with HTML file at {}",
73            name,
74            html_file_path.display()
75        )
76    } else {
77        format!(
78            "Template '{}' created. Create HTML file at {}",
79            name,
80            html_file_path.display()
81        )
82    };
83
84    CliService::success(&message);
85
86    let output = TemplateCreateOutput {
87        name,
88        file_path: html_file_path.to_string_lossy().to_string(),
89        message,
90    };
91
92    Ok(CommandOutput::card_value("Template Created", &output))
93}
94
95fn load_templates_config(templates_yaml_path: &Path) -> Result<TemplatesConfig> {
96    let yaml_content = fs::read_to_string(templates_yaml_path).with_context(|| {
97        format!(
98            "Failed to read templates config at {}",
99            templates_yaml_path.display()
100        )
101    })?;
102
103    serde_yaml::from_str(&yaml_content).with_context(|| {
104        format!(
105            "Failed to parse templates config at {}",
106            templates_yaml_path.display()
107        )
108    })
109}
110
111fn save_templates_config(templates_yaml_path: &Path, config: &TemplatesConfig) -> Result<()> {
112    let yaml = serde_yaml::to_string(config).context("Failed to serialize config")?;
113    fs::write(templates_yaml_path, yaml).with_context(|| {
114        format!(
115            "Failed to write templates config to {}",
116            templates_yaml_path.display()
117        )
118    })
119}
120
121fn resolve_content_types(
122    arg: Option<String>,
123    prompter: &dyn Prompter,
124    config: &CliConfig,
125) -> Result<Vec<String>> {
126    let content_types: Vec<String> = if let Some(ct) = arg {
127        ct.split(',').map(|s| s.trim().to_owned()).collect()
128    } else if config.is_interactive() {
129        prompt_content_types(prompter)?
130    } else {
131        return Err(anyhow!(
132            "--content-types is required in non-interactive mode"
133        ));
134    };
135
136    if content_types.is_empty() {
137        return Err(anyhow!("At least one content type is required"));
138    }
139
140    Ok(content_types)
141}
142
143fn read_html_content(content_source: &str) -> Result<String> {
144    if content_source == "-" {
145        let mut buffer = String::new();
146        io::stdin()
147            .read_to_string(&mut buffer)
148            .context("Failed to read from stdin")?;
149        Ok(buffer)
150    } else if Path::new(content_source).exists() {
151        fs::read_to_string(content_source)
152            .with_context(|| format!("Failed to read file: {}", content_source))
153    } else {
154        Ok(content_source.to_owned())
155    }
156}
157
158pub fn prompt_name(prompter: &dyn Prompter) -> Result<String> {
159    loop {
160        let input = prompter.input("Template name")?;
161        let trimmed = input.trim();
162        match validate_template_name(trimmed) {
163            Ok(()) => return Ok(trimmed.to_owned()),
164            Err(message) => CliService::warning(message),
165        }
166    }
167}
168
169fn validate_template_name(input: &str) -> Result<(), &'static str> {
170    if input.len() < 2 {
171        return Err("Name must be at least 2 characters");
172    }
173    if !input
174        .chars()
175        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
176    {
177        return Err("Name must be lowercase alphanumeric with hyphens only");
178    }
179    Ok(())
180}
181
182pub fn prompt_content_types(prompter: &dyn Prompter) -> Result<Vec<String>> {
183    let input = prompter.input("Content types (comma-separated)")?;
184    Ok(input.split(',').map(|s| s.trim().to_owned()).collect())
185}