tl_cli/style/
mod.rs

1//! Translation style management.
2//!
3//! Provides preset and custom translation styles to control the tone
4//! and style of translations.
5
6use std::collections::HashMap;
7
8use crate::config::CustomStyle;
9
10/// A preset translation style (hardcoded, not modifiable by users).
11#[derive(Debug, Clone)]
12pub struct PresetStyle {
13    /// The style key (e.g., "casual", "formal").
14    pub key: &'static str,
15    /// Human-readable description.
16    pub description: &'static str,
17    /// Prompt text appended to the system prompt.
18    pub prompt: &'static str,
19}
20
21/// All available preset styles.
22pub const PRESETS: &[PresetStyle] = &[
23    PresetStyle {
24        key: "casual",
25        description: "Casual, conversational tone",
26        prompt: "Use a casual, friendly, conversational tone.",
27    },
28    PresetStyle {
29        key: "formal",
30        description: "Formal, business-appropriate",
31        prompt: "Use a formal, polite, business-appropriate tone.",
32    },
33    PresetStyle {
34        key: "literal",
35        description: "Literal, close to source",
36        prompt: "Translate as literally as possible while remaining grammatical.",
37    },
38    PresetStyle {
39        key: "natural",
40        description: "Natural, idiomatic",
41        prompt: "Translate naturally, prioritizing idiomatic expressions over literal accuracy.",
42    },
43];
44
45/// Resolved style information.
46#[derive(Debug, Clone)]
47pub enum ResolvedStyle {
48    /// A preset style.
49    Preset(&'static PresetStyle),
50    /// A custom user-defined style.
51    Custom { key: String, prompt: String },
52}
53
54impl ResolvedStyle {
55    /// Returns the prompt text for this style.
56    pub fn prompt(&self) -> &str {
57        match self {
58            Self::Preset(preset) => preset.prompt,
59            Self::Custom { prompt, .. } => prompt,
60        }
61    }
62
63    /// Returns the key for this style.
64    pub fn key(&self) -> &str {
65        match self {
66            Self::Preset(preset) => preset.key,
67            Self::Custom { key, .. } => key,
68        }
69    }
70}
71
72/// Looks up a preset style by key.
73pub fn get_preset(key: &str) -> Option<&'static PresetStyle> {
74    PRESETS.iter().find(|p| p.key == key)
75}
76
77/// Returns true if the key is a preset style.
78pub fn is_preset(key: &str) -> bool {
79    get_preset(key).is_some()
80}
81
82/// Returns custom style keys sorted alphabetically.
83#[allow(clippy::implicit_hasher)]
84pub fn sorted_custom_keys(styles: &HashMap<String, CustomStyle>) -> Vec<&String> {
85    let mut keys: Vec<_> = styles.keys().collect();
86    keys.sort();
87    keys
88}
89
90/// Resolves a style key to a `ResolvedStyle`.
91///
92/// First checks presets, then custom styles.
93/// Returns an error if the style is not found.
94#[allow(clippy::implicit_hasher)]
95pub fn resolve_style(
96    key: &str,
97    custom_styles: &HashMap<String, CustomStyle>,
98) -> Result<ResolvedStyle, StyleError> {
99    // Check presets first
100    if let Some(preset) = get_preset(key) {
101        return Ok(ResolvedStyle::Preset(preset));
102    }
103
104    // Check custom styles
105    if let Some(custom) = custom_styles.get(key) {
106        return Ok(ResolvedStyle::Custom {
107            key: key.to_string(),
108            prompt: custom.prompt.clone(),
109        });
110    }
111
112    let custom_keys: Vec<String> = sorted_custom_keys(custom_styles)
113        .into_iter()
114        .cloned()
115        .collect();
116    Err(StyleError::NotFound {
117        key: key.to_string(),
118        custom_keys,
119    })
120}
121
122/// Style-related errors.
123#[derive(Debug, Clone)]
124pub enum StyleError {
125    /// Style not found. Contains the key and list of custom style keys.
126    NotFound {
127        key: String,
128        custom_keys: Vec<String>,
129    },
130    /// Attempted to modify a preset style.
131    PresetImmutable(String),
132    /// Style key already exists.
133    AlreadyExists(String),
134    /// Invalid style key format.
135    InvalidKey(String),
136}
137
138impl std::fmt::Display for StyleError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            Self::NotFound { key, custom_keys } => {
142                let preset_keys: Vec<_> = PRESETS.iter().map(|p| p.key).collect();
143                let mut all_keys: Vec<&str> = preset_keys;
144                all_keys.extend(custom_keys.iter().map(String::as_str));
145                write!(
146                    f,
147                    "Style '{key}' not found\n\nAvailable styles: {}",
148                    all_keys.join(", ")
149                )
150            }
151            Self::PresetImmutable(key) => {
152                write!(f, "Cannot modify preset style '{key}'")
153            }
154            Self::AlreadyExists(key) => {
155                write!(f, "Style '{key}' already exists")
156            }
157            Self::InvalidKey(key) => {
158                write!(
159                    f,
160                    "Invalid style key '{key}': must start with a letter and contain only alphanumeric characters and underscores"
161                )
162            }
163        }
164    }
165}
166
167impl std::error::Error for StyleError {}
168
169/// Validates a custom style key.
170///
171/// Keys must start with a letter, contain only alphanumeric characters and underscores,
172/// and cannot conflict with presets.
173pub fn validate_custom_key(key: &str) -> Result<(), StyleError> {
174    // Check empty first
175    if key.is_empty() {
176        return Err(StyleError::InvalidKey(key.to_string()));
177    }
178
179    // Must start with a letter
180    if !key.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
181        return Err(StyleError::InvalidKey(key.to_string()));
182    }
183
184    // All characters must be alphanumeric or underscore
185    if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
186        return Err(StyleError::InvalidKey(key.to_string()));
187    }
188
189    // Cannot conflict with presets
190    if is_preset(key) {
191        return Err(StyleError::PresetImmutable(key.to_string()));
192    }
193
194    Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_preset_count() {
203        assert_eq!(PRESETS.len(), 4);
204    }
205
206    #[test]
207    fn test_get_preset_exists() {
208        assert!(get_preset("casual").is_some());
209        assert!(get_preset("formal").is_some());
210        assert!(get_preset("literal").is_some());
211        assert!(get_preset("natural").is_some());
212    }
213
214    #[test]
215    fn test_get_preset_not_exists() {
216        assert!(get_preset("nonexistent").is_none());
217    }
218
219    #[test]
220    fn test_is_preset() {
221        assert!(is_preset("casual"));
222        assert!(!is_preset("my_custom"));
223    }
224
225    #[test]
226    fn test_sorted_custom_keys() {
227        let mut styles = HashMap::new();
228        styles.insert(
229            "zebra".to_string(),
230            CustomStyle {
231                description: "z desc".to_string(),
232                prompt: "z prompt".to_string(),
233            },
234        );
235        styles.insert(
236            "alpha".to_string(),
237            CustomStyle {
238                description: "a desc".to_string(),
239                prompt: "a prompt".to_string(),
240            },
241        );
242        styles.insert(
243            "beta".to_string(),
244            CustomStyle {
245                description: "b desc".to_string(),
246                prompt: "b prompt".to_string(),
247            },
248        );
249
250        let keys = sorted_custom_keys(&styles);
251        assert_eq!(keys, vec!["alpha", "beta", "zebra"]);
252    }
253
254    #[test]
255    fn test_sorted_custom_keys_empty() {
256        let styles: HashMap<String, CustomStyle> = HashMap::new();
257        let keys = sorted_custom_keys(&styles);
258        assert!(keys.is_empty());
259    }
260
261    #[test]
262    fn test_resolve_style_preset() {
263        let custom: HashMap<String, CustomStyle> = HashMap::new();
264        let resolved = resolve_style("casual", &custom);
265        assert!(resolved.is_ok());
266        assert_eq!(
267            resolved.as_ref().ok().map(ResolvedStyle::key),
268            Some("casual")
269        );
270    }
271
272    #[test]
273    fn test_resolve_style_custom() {
274        let mut custom = HashMap::new();
275        custom.insert(
276            "my_style".to_string(),
277            CustomStyle {
278                description: "My description".to_string(),
279                prompt: "My custom prompt".to_string(),
280            },
281        );
282
283        let resolved = resolve_style("my_style", &custom);
284        assert!(resolved.is_ok());
285        assert_eq!(
286            resolved.as_ref().ok().map(ResolvedStyle::key),
287            Some("my_style")
288        );
289        assert_eq!(
290            resolved.as_ref().ok().map(ResolvedStyle::prompt),
291            Some("My custom prompt")
292        );
293    }
294
295    #[test]
296    fn test_resolve_style_not_found() {
297        let custom: HashMap<String, CustomStyle> = HashMap::new();
298        let resolved = resolve_style("nonexistent", &custom);
299        assert!(resolved.is_err());
300    }
301
302    #[test]
303    fn test_validate_custom_key_valid() {
304        assert!(validate_custom_key("my_style").is_ok());
305        assert!(validate_custom_key("style123").is_ok());
306        assert!(validate_custom_key("MyStyle").is_ok());
307    }
308
309    #[test]
310    fn test_validate_custom_key_preset() {
311        let result = validate_custom_key("casual");
312        assert!(matches!(result, Err(StyleError::PresetImmutable(_))));
313    }
314
315    #[test]
316    fn test_validate_custom_key_invalid() {
317        assert!(validate_custom_key("").is_err());
318        assert!(validate_custom_key("123start").is_err());
319        assert!(validate_custom_key("has-dash").is_err());
320        assert!(validate_custom_key("has space").is_err());
321    }
322
323    #[test]
324    fn test_validate_custom_key_underscore_prefix() {
325        // Underscore at start is invalid (must start with letter)
326        assert!(matches!(
327            validate_custom_key("_style"),
328            Err(StyleError::InvalidKey(_))
329        ));
330    }
331
332    #[test]
333    fn test_validate_custom_key_single_letter() {
334        // Single letter is valid
335        assert!(validate_custom_key("a").is_ok());
336    }
337
338    // StyleError display tests
339
340    #[test]
341    fn test_style_error_not_found_display_shows_presets() {
342        let error = StyleError::NotFound {
343            key: "unknown".to_string(),
344            custom_keys: vec![],
345        };
346        let msg = error.to_string();
347        assert!(msg.contains("Style 'unknown' not found"));
348        assert!(msg.contains("casual"));
349        assert!(msg.contains("formal"));
350        assert!(msg.contains("literal"));
351        assert!(msg.contains("natural"));
352    }
353
354    #[test]
355    fn test_style_error_not_found_display_includes_custom() {
356        let error = StyleError::NotFound {
357            key: "unknown".to_string(),
358            custom_keys: vec!["my_custom".to_string(), "another".to_string()],
359        };
360        let msg = error.to_string();
361        assert!(msg.contains("Available styles:"));
362        assert!(msg.contains("casual"));
363        assert!(msg.contains("my_custom"));
364        assert!(msg.contains("another"));
365    }
366
367    #[test]
368    fn test_style_error_preset_immutable_display() {
369        let error = StyleError::PresetImmutable("casual".to_string());
370        let msg = error.to_string();
371        assert!(msg.contains("Cannot modify preset style 'casual'"));
372    }
373
374    #[test]
375    fn test_style_error_already_exists_display() {
376        let error = StyleError::AlreadyExists("my_style".to_string());
377        let msg = error.to_string();
378        assert!(msg.contains("Style 'my_style' already exists"));
379    }
380
381    #[test]
382    fn test_style_error_invalid_key_display() {
383        let error = StyleError::InvalidKey("123bad".to_string());
384        let msg = error.to_string();
385        assert!(msg.contains("Invalid style key '123bad'"));
386        assert!(msg.contains("must start with a letter"));
387    }
388
389    #[test]
390    fn test_resolve_style_error_includes_custom_keys() {
391        let mut custom = HashMap::new();
392        custom.insert(
393            "my_style".to_string(),
394            CustomStyle {
395                description: "desc".to_string(),
396                prompt: "prompt".to_string(),
397            },
398        );
399
400        let result = resolve_style("nonexistent", &custom);
401        match result {
402            Err(StyleError::NotFound { custom_keys, .. }) => {
403                assert!(custom_keys.contains(&"my_style".to_string()));
404            }
405            _ => panic!("Expected StyleError::NotFound"),
406        }
407    }
408}