systemprompt_cli/commands/web/templates/
edit.rs1use 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::{TemplateEditOutput, TemplateEntry, TemplatesConfig};
14use super::selection::prompt_template_selection;
15
16#[derive(Debug, Args)]
17pub struct EditArgs {
18 #[arg(help = "Template name")]
19 pub name: Option<String>,
20
21 #[arg(long, help = "Add content type to template")]
22 pub add_content_type: Option<String>,
23
24 #[arg(long, help = "Remove content type from template")]
25 pub remove_content_type: Option<String>,
26
27 #[arg(long, help = "Replace HTML content (use '-' for stdin)")]
28 pub content: Option<String>,
29
30 #[arg(long, help = "Set content types (comma-separated, replaces existing)")]
31 pub content_types: Option<String>,
32}
33
34pub(super) fn execute(
35 args: EditArgs,
36 prompter: &dyn Prompter,
37 config: &CliConfig,
38) -> Result<CommandOutput> {
39 execute_in_dir(args, prompter, config, &WebPaths::resolve()?.templates)
40}
41
42pub fn execute_in_dir(
43 args: EditArgs,
44 prompter: &dyn Prompter,
45 config: &CliConfig,
46 templates_dir: &Path,
47) -> Result<CommandOutput> {
48 let templates_yaml_path = templates_dir.join("templates.yaml");
49
50 let mut templates_config = load_templates_config(&templates_yaml_path)?;
51
52 let EditArgs {
53 name,
54 add_content_type,
55 remove_content_type,
56 content,
57 content_types,
58 } = args;
59
60 let name = resolve_required(name, "name", config, || {
61 prompt_template_selection(prompter, &templates_config, "Select template to edit")
62 })?;
63
64 let entry = templates_config
65 .templates
66 .get_mut(&name)
67 .ok_or_else(|| anyhow!("Template '{}' not found", name))?;
68
69 let edits = ContentTypeEdits {
70 set: content_types,
71 add: add_content_type,
72 remove: remove_content_type,
73 };
74 let mut changes = apply_content_type_edits(entry, edits)?;
75
76 if let Some(content_source) = &content {
77 let html_content = read_html_content(content_source)?;
78 let html_file_path = templates_dir.join(format!("{}.html", name));
79 fs::write(&html_file_path, html_content)
80 .with_context(|| format!("Failed to write HTML file: {}", html_file_path.display()))?;
81 changes.push(format!("updated HTML file: {}", html_file_path.display()));
82 }
83
84 if changes.is_empty() {
85 return Err(anyhow!(
86 "No changes specified. Use --add-content-type, --remove-content-type, \
87 --content-types, or --content"
88 ));
89 }
90
91 save_templates_config(&templates_yaml_path, &templates_config)?;
92
93 CliService::success(&format!("Template '{}' updated successfully", name));
94
95 let output = TemplateEditOutput {
96 name: name.clone(),
97 message: format!(
98 "Template '{}' updated successfully with {} change(s)",
99 name,
100 changes.len()
101 ),
102 changes,
103 };
104
105 Ok(CommandOutput::card_value(
106 format!("Edit Template: {}", name),
107 &output,
108 ))
109}
110
111fn load_templates_config(templates_yaml_path: &Path) -> Result<TemplatesConfig> {
112 let yaml_content = fs::read_to_string(templates_yaml_path).with_context(|| {
113 format!(
114 "Failed to read templates config at {}",
115 templates_yaml_path.display()
116 )
117 })?;
118
119 serde_yaml::from_str(&yaml_content).with_context(|| {
120 format!(
121 "Failed to parse templates config at {}",
122 templates_yaml_path.display()
123 )
124 })
125}
126
127fn save_templates_config(templates_yaml_path: &Path, config: &TemplatesConfig) -> Result<()> {
128 let yaml = serde_yaml::to_string(config).context("Failed to serialize config")?;
129 fs::write(templates_yaml_path, yaml).with_context(|| {
130 format!(
131 "Failed to write templates config to {}",
132 templates_yaml_path.display()
133 )
134 })
135}
136
137struct ContentTypeEdits {
138 set: Option<String>,
139 add: Option<String>,
140 remove: Option<String>,
141}
142
143fn apply_content_type_edits(
144 entry: &mut TemplateEntry,
145 edits: ContentTypeEdits,
146) -> Result<Vec<String>> {
147 let mut changes = Vec::new();
148
149 if let Some(ct) = edits.set {
150 let new_types: Vec<String> = ct.split(',').map(|s| s.trim().to_owned()).collect();
151 entry.content_types.clone_from(&new_types);
152 changes.push(format!("content_types: {:?}", new_types));
153 }
154
155 if let Some(add_type) = edits.add {
156 if entry.content_types.contains(&add_type) {
157 CliService::warning(&format!(
158 "Content type '{}' already linked to template",
159 add_type
160 ));
161 } else {
162 entry.content_types.push(add_type.clone());
163 changes.push(format!("added content_type: {}", add_type));
164 }
165 }
166
167 if let Some(remove_type) = edits.remove {
168 if let Some(pos) = entry.content_types.iter().position(|x| *x == remove_type) {
169 entry.content_types.remove(pos);
170 changes.push(format!("removed content_type: {}", remove_type));
171 } else {
172 return Err(anyhow!(
173 "Content type '{}' not linked to template",
174 remove_type
175 ));
176 }
177 }
178
179 Ok(changes)
180}
181
182fn read_html_content(content_source: &str) -> Result<String> {
183 if content_source == "-" {
184 let mut buffer = String::new();
185 io::stdin()
186 .read_to_string(&mut buffer)
187 .context("Failed to read from stdin")?;
188 Ok(buffer)
189 } else if Path::new(content_source).exists() {
190 fs::read_to_string(content_source)
191 .with_context(|| format!("Failed to read file: {}", content_source))
192 } else {
193 Ok(content_source.to_owned())
194 }
195}