Skip to main content

systemprompt_cli/commands/web/templates/
edit.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::{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}