tl_cli/cli/commands/
styles.rs

1//! Styles command handler for managing translation styles.
2
3use anyhow::{Result, bail};
4use inquire::{Confirm, Editor, Text};
5
6use super::load_config;
7use crate::config::CustomStyle;
8use crate::style::{PRESETS, get_preset, is_preset, sorted_custom_keys, validate_custom_key};
9use crate::ui::{Style, handle_prompt_cancellation};
10
11/// Lists all available styles (presets and custom).
12pub fn list_styles() -> Result<()> {
13    let (_manager, config) = load_config()?;
14
15    // Print preset styles
16    println!("{}", Style::header("Preset styles"));
17    for preset in PRESETS {
18        println!(
19            "  {}  {}",
20            Style::value(format!("{:10}", preset.key)),
21            Style::secondary(preset.description)
22        );
23    }
24
25    // Print custom styles if any
26    if !config.styles.is_empty() {
27        println!();
28        println!("{}", Style::header("Custom styles"));
29        for key in sorted_custom_keys(&config.styles) {
30            let description = config
31                .styles
32                .get(key)
33                .map_or("", |s| s.description.as_str());
34            println!(
35                "  {}  {}",
36                Style::value(format!("{key:10}")),
37                Style::secondary(description)
38            );
39        }
40    }
41
42    Ok(())
43}
44
45/// Shows details of a style (description and prompt).
46pub fn show_style(name: &str) -> Result<()> {
47    // Check preset first
48    if let Some(preset) = get_preset(name) {
49        println!("{}", Style::header("Preset style"));
50        println!();
51        println!("  {}  {}", Style::label("Name:"), Style::value(preset.key));
52        println!(
53            "  {}  {}",
54            Style::label("Desc:"),
55            Style::secondary(preset.description)
56        );
57        println!();
58        println!("{}", Style::label("Prompt:"));
59        println!("{}", preset.prompt);
60        return Ok(());
61    }
62
63    // Check custom styles
64    let (_manager, config) = load_config()?;
65
66    let custom = config
67        .styles
68        .get(name)
69        .ok_or_else(|| anyhow::anyhow!("Style '{name}' not found"))?;
70
71    println!("{}", Style::header("Custom style"));
72    println!();
73    println!("{}  {}", Style::label("Name:"), Style::value(name));
74    println!(
75        "{}  {}",
76        Style::label("Desc:"),
77        Style::secondary(&custom.description)
78    );
79    println!();
80    println!("{}", Style::label("Prompt:"));
81    println!("{}", custom.prompt);
82
83    Ok(())
84}
85
86/// Adds a new custom style interactively.
87pub fn add_style() -> Result<()> {
88    handle_prompt_cancellation(add_style_inner)
89}
90
91fn add_style_inner() -> Result<()> {
92    let (manager, mut config) = load_config()?;
93
94    // Get style name
95    let name = Text::new("Style name:")
96        .with_help_message("Alphanumeric and underscores only (e.g., my_style)")
97        .prompt()?;
98
99    let name = name.trim().to_string();
100
101    // Validate name
102    validate_custom_key(&name).map_err(|e| anyhow::anyhow!("{e}"))?;
103
104    // Check if already exists
105    if config.styles.contains_key(&name) {
106        bail!("Style '{name}' already exists. Use 'tl styles edit {name}' to modify it.");
107    }
108
109    // Get style description (short, for display)
110    let description = Text::new("Description:")
111        .with_help_message("Short description for display (e.g., \"Ojisan-style texting\")")
112        .prompt()?;
113
114    let description = description.trim().to_string();
115
116    if description.is_empty() {
117        bail!("Description cannot be empty");
118    }
119
120    // Get style prompt (instructions for LLM) using editor
121    let prompt = Editor::new("Prompt (opens editor):")
122        .with_help_message("Instructions for the LLM. Save and close editor when done.")
123        .with_predefined_text(
124            "# Enter the prompt for the LLM below.\n# Lines starting with # are ignored.\n\n",
125        )
126        .prompt()?;
127
128    let prompt = filter_comment_lines(&prompt);
129
130    if prompt.is_empty() {
131        bail!("Prompt cannot be empty");
132    }
133
134    // Save
135    config.styles.insert(
136        name.clone(),
137        CustomStyle {
138            description,
139            prompt,
140        },
141    );
142    manager.save(&config)?;
143
144    println!();
145    println!(
146        "{} Style '{}' added",
147        Style::success("✓"),
148        Style::value(&name)
149    );
150
151    Ok(())
152}
153
154/// Filters out comment lines (starting with #) and trims the result.
155fn filter_comment_lines(text: &str) -> String {
156    text.lines()
157        .filter(|line| !line.trim_start().starts_with('#'))
158        .collect::<Vec<_>>()
159        .join("\n")
160        .trim()
161        .to_string()
162}
163
164/// Edits an existing custom style.
165pub fn edit_style(name: &str) -> Result<()> {
166    handle_prompt_cancellation(|| edit_style_inner(name))
167}
168
169fn edit_style_inner(name: &str) -> Result<()> {
170    // Check if it's a preset
171    if is_preset(name) {
172        bail!("Cannot edit preset style '{name}'. Preset styles are immutable.");
173    }
174
175    let (manager, mut config) = load_config()?;
176
177    // Check if exists
178    let current = config.styles.get(name).cloned().ok_or_else(|| {
179        anyhow::anyhow!("Style '{name}' not found. Use 'tl styles add' to create it.")
180    })?;
181
182    println!(
183        "{} '{}':",
184        Style::header("Editing style"),
185        Style::value(name)
186    );
187    println!();
188
189    // Get new description
190    let description = Text::new("Description:")
191        .with_default(&current.description)
192        .prompt()?;
193
194    let description = description.trim().to_string();
195
196    if description.is_empty() {
197        bail!("Description cannot be empty");
198    }
199
200    // Get new prompt using editor
201    let prompt = Editor::new("Prompt (opens editor):")
202        .with_help_message("Edit the prompt for the LLM. Save and close editor when done.")
203        .with_predefined_text(&current.prompt)
204        .prompt()?;
205
206    let prompt = prompt.trim().to_string();
207
208    if prompt.is_empty() {
209        bail!("Prompt cannot be empty");
210    }
211
212    // Save
213    config.styles.insert(
214        name.to_string(),
215        CustomStyle {
216            description,
217            prompt,
218        },
219    );
220    manager.save(&config)?;
221
222    println!();
223    println!(
224        "{} Style '{}' updated",
225        Style::success("✓"),
226        Style::value(name)
227    );
228
229    Ok(())
230}
231
232/// Removes a custom style.
233pub fn remove_style(name: &str) -> Result<()> {
234    handle_prompt_cancellation(|| remove_style_inner(name))
235}
236
237fn remove_style_inner(name: &str) -> Result<()> {
238    // Check if it's a preset
239    if is_preset(name) {
240        bail!("Cannot remove preset style '{name}'. Preset styles are immutable.");
241    }
242
243    let (manager, mut config) = load_config()?;
244
245    // Check if exists
246    if !config.styles.contains_key(name) {
247        bail!("Style '{name}' not found");
248    }
249
250    // Confirm removal
251    let confirm = Confirm::new(&format!("Remove style '{name}'?"))
252        .with_default(false)
253        .prompt()?;
254
255    if !confirm {
256        println!("Cancelled");
257        return Ok(());
258    }
259
260    // Warn if it's the default style
261    if config.tl.style.as_deref() == Some(name) {
262        println!(
263            "{} This is your default style. You may want to run 'tl configure' to set a new default.",
264            Style::warning("Warning:")
265        );
266    }
267
268    // Remove
269    config.styles.remove(name);
270    manager.save(&config)?;
271
272    println!();
273    println!(
274        "{} Style '{}' removed",
275        Style::success("✓"),
276        Style::value(name)
277    );
278
279    Ok(())
280}