Skip to main content

systemprompt_cli/commands/web/templates/
edit.rs

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