systemprompt_cli/commands/web/templates/
create.rs1use 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 if content_types.is_empty() {
142 return Err(anyhow!("At least one content type is required"));
143 }
144
145 Ok(content_types)
146}
147
148fn read_html_content(content_source: &str) -> Result<String> {
149 if content_source == "-" {
150 let mut buffer = String::new();
151 io::stdin()
152 .read_to_string(&mut buffer)
153 .context("Failed to read from stdin")?;
154 Ok(buffer)
155 } else if Path::new(content_source).exists() {
156 fs::read_to_string(content_source)
157 .with_context(|| format!("Failed to read file: {}", content_source))
158 } else {
159 Ok(content_source.to_owned())
160 }
161}
162
163pub fn prompt_name(prompter: &dyn Prompter) -> Result<String> {
164 loop {
165 let input = prompter.input("Template name")?;
166 let trimmed = input.trim();
167 match validate_template_name(trimmed) {
168 Ok(()) => return Ok(trimmed.to_owned()),
169 Err(message) => CliService::warning(message),
170 }
171 }
172}
173
174fn validate_template_name(input: &str) -> Result<(), &'static str> {
175 if input.len() < 2 {
176 return Err("Name must be at least 2 characters");
177 }
178 if !input
179 .chars()
180 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
181 {
182 return Err("Name must be lowercase alphanumeric with hyphens only");
183 }
184 Ok(())
185}
186
187pub fn prompt_content_types(prompter: &dyn Prompter) -> Result<Vec<String>> {
188 let input = prompter.input("Content types (comma-separated)")?;
189 Ok(input.split(',').map(|s| s.trim().to_owned()).collect())
190}