Skip to main content

systemprompt_cli/commands/web/templates/
create.rs

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