tl_cli/cli/commands/
translate.rs

1use anyhow::{Result, bail};
2use futures_util::StreamExt;
3use std::fs;
4use std::io::{self, Write};
5use std::path::Path;
6
7use crate::cache::CacheManager;
8use crate::config::{ConfigFile, ConfigManager, ResolvedConfig};
9use crate::input::InputReader;
10use crate::translation::{TranslationClient, TranslationRequest};
11use crate::ui::Spinner;
12
13/// Write content to file atomically using a temp file and rename.
14/// This prevents file corruption if interrupted (e.g., Ctrl+C).
15fn atomic_write(file_path: &str, content: &str) -> Result<()> {
16    let path = Path::new(file_path);
17    let parent = path.parent().unwrap_or_else(|| Path::new("."));
18    let file_name = path.file_name().unwrap_or_default().to_string_lossy();
19    let temp_path = parent.join(format!(".{file_name}.tmp"));
20
21    // Write to temp file first
22    fs::write(&temp_path, content)?;
23
24    // Atomic rename (same filesystem)
25    fs::rename(&temp_path, file_path)?;
26
27    Ok(())
28}
29
30/// Options for the translate command.
31pub struct TranslateOptions {
32    /// Input file path (reads from stdin if `None`).
33    pub file: Option<String>,
34    /// Target language code.
35    pub to: Option<String>,
36    /// Provider name.
37    pub provider: Option<String>,
38    /// Model name.
39    pub model: Option<String>,
40    /// Whether to bypass the cache.
41    pub no_cache: bool,
42    /// Whether to overwrite the input file with the translation.
43    pub write: bool,
44}
45
46/// Runs the translate command.
47///
48/// Translates input from a file or stdin and outputs the result.
49/// Supports caching and streaming output.
50pub async fn run_translate(options: TranslateOptions) -> Result<()> {
51    // Validate -w option requires a file
52    if options.write && options.file.is_none() {
53        bail!("Error: --write requires a file argument (cannot write to stdin)");
54    }
55
56    let manager = ConfigManager::new()?;
57    let config_file = manager.load_or_default();
58    let resolved = resolve_config(&options, &config_file)?;
59
60    let source_text = InputReader::read(options.file.as_deref())?;
61
62    if source_text.is_empty() {
63        bail!("Error: Input is empty");
64    }
65
66    let cache_manager = CacheManager::new()?;
67
68    // Create request first, moving values where possible
69    // Only endpoint needs clone (used by both client and request)
70    let request = TranslationRequest {
71        source_text,
72        target_language: resolved.target_language,
73        model: resolved.model,
74        endpoint: resolved.endpoint.clone(),
75    };
76
77    // Create client with remaining values (endpoint cloned, api_key moved)
78    let client = TranslationClient::new(resolved.endpoint, resolved.api_key);
79
80    if !options.no_cache
81        && let Some(cached) = cache_manager.get(&request)?
82    {
83        if options.write {
84            if let Some(ref file_path) = options.file {
85                atomic_write(file_path, &cached)?;
86            }
87        } else {
88            print!("{cached}");
89            io::stdout().flush()?;
90        }
91        return Ok(());
92    }
93
94    let spinner_msg = if options.write {
95        format!(
96            "Translating {}...",
97            options.file.as_deref().unwrap_or("file")
98        )
99    } else {
100        "Translating...".to_string()
101    };
102    let spinner = Spinner::new(&spinner_msg);
103
104    let mut stream = client.translate_stream(&request).await?;
105    let mut full_response = String::new();
106    let mut spinner_active = true;
107
108    while let Some(chunk_result) = stream.next().await {
109        let chunk = chunk_result?;
110
111        // When streaming to stdout, stop spinner on first chunk to show output
112        // When writing to file, keep spinner until completion
113        if spinner_active && !options.write {
114            spinner.stop();
115            spinner_active = false;
116        }
117
118        if !options.write {
119            print!("{chunk}");
120            io::stdout().flush()?;
121        }
122        full_response.push_str(&chunk);
123    }
124
125    if spinner_active {
126        spinner.stop();
127    }
128
129    if !options.write && !full_response.is_empty() {
130        println!();
131    }
132
133    if !options.no_cache && !full_response.is_empty() {
134        cache_manager.put(&request, &full_response)?;
135    }
136
137    // Write to file if -w is specified
138    if options.write
139        && !full_response.is_empty()
140        && let Some(ref file_path) = options.file
141    {
142        atomic_write(file_path, &full_response)?;
143    }
144
145    Ok(())
146}
147
148/// Resolves configuration by merging CLI options with config file settings.
149///
150/// CLI options take precedence over config file values.
151///
152/// # Errors
153///
154/// Returns an error if required configuration (provider, model, target language)
155/// is missing or if the specified provider is not found.
156pub fn resolve_config(
157    options: &TranslateOptions,
158    config_file: &ConfigFile,
159) -> Result<ResolvedConfig> {
160    // Resolve provider (clone only the value we use, not both)
161    let provider_name = options
162        .provider
163        .as_ref()
164        .or(config_file.tl.provider.as_ref())
165        .cloned()
166        .ok_or_else(|| {
167            anyhow::anyhow!(
168                "Error: Missing required configuration: 'provider'\n\n\
169                 Please provide it via:\n  \
170                 - CLI option: tl --provider <name>\n  \
171                 - Config file: ~/.config/tl/config.toml"
172            )
173        })?;
174
175    // Get provider config
176    let provider_config = config_file.providers.get(&provider_name).ok_or_else(|| {
177        let available: Vec<_> = config_file.providers.keys().collect();
178        if available.is_empty() {
179            anyhow::anyhow!(
180                "Error: Provider '{provider_name}' not found\n\n\
181                 No providers configured. Add providers to ~/.config/tl/config.toml"
182            )
183        } else {
184            anyhow::anyhow!(
185                "Error: Provider '{provider_name}' not found\n\n\
186                 Available providers:\n  \
187                 - {}\n\n\
188                 Add providers to ~/.config/tl/config.toml",
189                available
190                    .iter()
191                    .map(|s| s.as_str())
192                    .collect::<Vec<_>>()
193                    .join("\n  - ")
194            )
195        }
196    })?;
197
198    // Resolve model (clone only the value we use, not both)
199    let model = options
200        .model
201        .as_ref()
202        .or(config_file.tl.model.as_ref())
203        .cloned()
204        .ok_or_else(|| {
205            anyhow::anyhow!(
206                "Error: Missing required configuration: 'model'\n\n\
207                 Please provide it via:\n  \
208                 - CLI option: tl --model <name>\n  \
209                 - Config file: ~/.config/tl/config.toml"
210            )
211        })?;
212
213    // Warn if model is not in provider's models list
214    if !provider_config.models.is_empty() && !provider_config.models.contains(&model) {
215        eprintln!(
216            "Warning: Model '{}' is not in the configured models list for '{}'\n\
217             Configured models: {}\n\
218             Proceeding anyway...\n",
219            model,
220            provider_name,
221            provider_config.models.join(", ")
222        );
223    }
224
225    // Resolve target language (clone only the value we use, not both)
226    let target_language = options
227        .to
228        .as_ref()
229        .or(config_file.tl.to.as_ref())
230        .cloned()
231        .ok_or_else(|| {
232            anyhow::anyhow!(
233                "Error: Missing required configuration: 'to' (target language)\n\n\
234                 Please provide it via:\n  \
235                 - CLI option: tl --to <lang>\n  \
236                 - Config file: ~/.config/tl/config.toml"
237            )
238        })?;
239
240    // Get API key
241    let api_key = provider_config.get_api_key();
242
243    // Check if API key is required but missing
244    if provider_config.requires_api_key() && api_key.is_none() {
245        let env_var = provider_config.api_key_env.as_deref().unwrap_or("API_KEY");
246        bail!(
247            "Error: Provider '{provider_name}' requires an API key\n\n\
248             Set the {env_var} environment variable:\n  \
249             export {env_var}=\"your-api-key\"\n\n\
250             Or set api_key in ~/.config/tl/config.toml"
251        );
252    }
253
254    Ok(ResolvedConfig {
255        provider_name,
256        endpoint: provider_config.endpoint.clone(),
257        model,
258        api_key,
259        target_language,
260    })
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used)]
265mod tests {
266    use super::*;
267    use crate::config::{ProviderConfig, TlConfig};
268    use std::collections::HashMap;
269    use tempfile::TempDir;
270
271    fn create_test_options() -> TranslateOptions {
272        TranslateOptions {
273            file: None,
274            to: Some("ja".to_string()),
275            provider: Some("ollama".to_string()),
276            model: Some("gemma3:12b".to_string()),
277            no_cache: false,
278            write: false,
279        }
280    }
281
282    fn create_test_config() -> ConfigFile {
283        let mut providers = HashMap::new();
284        providers.insert(
285            "ollama".to_string(),
286            ProviderConfig {
287                endpoint: "http://localhost:11434".to_string(),
288                api_key: None,
289                api_key_env: None,
290                models: vec!["gemma3:12b".to_string()],
291            },
292        );
293        providers.insert(
294            "openrouter".to_string(),
295            ProviderConfig {
296                endpoint: "https://openrouter.ai/api".to_string(),
297                api_key: None,
298                api_key_env: Some("TL_TEST_NONEXISTENT_API_KEY".to_string()),
299                models: vec!["gpt-4o".to_string()],
300            },
301        );
302
303        ConfigFile {
304            tl: TlConfig {
305                provider: Some("ollama".to_string()),
306                model: Some("gemma3:12b".to_string()),
307                to: Some("ja".to_string()),
308            },
309            providers,
310        }
311    }
312
313    // atomic_write tests
314
315    #[test]
316    fn test_atomic_write_creates_file() {
317        let temp_dir = TempDir::new().unwrap();
318        let file_path = temp_dir.path().join("test.txt");
319        let file_path_str = file_path.to_str().unwrap();
320
321        atomic_write(file_path_str, "Hello, World!").unwrap();
322
323        let content = fs::read_to_string(&file_path).unwrap();
324        assert_eq!(content, "Hello, World!");
325    }
326
327    #[test]
328    fn test_atomic_write_overwrites_existing() {
329        let temp_dir = TempDir::new().unwrap();
330        let file_path = temp_dir.path().join("test.txt");
331        let file_path_str = file_path.to_str().unwrap();
332
333        fs::write(&file_path, "Original content").unwrap();
334        atomic_write(file_path_str, "New content").unwrap();
335
336        let content = fs::read_to_string(&file_path).unwrap();
337        assert_eq!(content, "New content");
338    }
339
340    #[test]
341    fn test_atomic_write_no_temp_file_remains() {
342        let temp_dir = TempDir::new().unwrap();
343        let file_path = temp_dir.path().join("test.txt");
344        let file_path_str = file_path.to_str().unwrap();
345
346        atomic_write(file_path_str, "content").unwrap();
347
348        // Temp file should not exist after successful write
349        let temp_path = temp_dir.path().join(".test.txt.tmp");
350        assert!(!temp_path.exists());
351    }
352
353    #[test]
354    fn test_atomic_write_unicode_content() {
355        let temp_dir = TempDir::new().unwrap();
356        let file_path = temp_dir.path().join("test.txt");
357        let file_path_str = file_path.to_str().unwrap();
358
359        let content = "こんにちは世界!🌍";
360        atomic_write(file_path_str, content).unwrap();
361
362        let read_content = fs::read_to_string(&file_path).unwrap();
363        assert_eq!(read_content, content);
364    }
365
366    // resolve_config tests
367
368    #[test]
369    fn test_resolve_config_with_cli_options() {
370        let options = create_test_options();
371        let config = create_test_config();
372
373        let resolved = resolve_config(&options, &config).unwrap();
374
375        assert_eq!(resolved.provider_name, "ollama");
376        assert_eq!(resolved.endpoint, "http://localhost:11434");
377        assert_eq!(resolved.model, "gemma3:12b");
378        assert_eq!(resolved.target_language, "ja");
379        assert!(resolved.api_key.is_none());
380    }
381
382    #[test]
383    fn test_resolve_config_cli_overrides_file() {
384        let mut options = create_test_options();
385        options.to = Some("en".to_string());
386        options.model = Some("llama3".to_string());
387
388        let config = create_test_config();
389
390        let resolved = resolve_config(&options, &config).unwrap();
391
392        assert_eq!(resolved.target_language, "en");
393        assert_eq!(resolved.model, "llama3");
394    }
395
396    #[test]
397    fn test_resolve_config_falls_back_to_file() {
398        let options = TranslateOptions {
399            file: None,
400            to: None,
401            provider: None,
402            model: None,
403            no_cache: false,
404            write: false,
405        };
406        let config = create_test_config();
407
408        let resolved = resolve_config(&options, &config).unwrap();
409
410        assert_eq!(resolved.provider_name, "ollama");
411        assert_eq!(resolved.model, "gemma3:12b");
412        assert_eq!(resolved.target_language, "ja");
413    }
414
415    #[test]
416    fn test_resolve_config_missing_provider() {
417        let options = TranslateOptions {
418            file: None,
419            to: Some("ja".to_string()),
420            provider: None,
421            model: Some("model".to_string()),
422            no_cache: false,
423            write: false,
424        };
425        let config = ConfigFile {
426            tl: TlConfig {
427                provider: None,
428                model: None,
429                to: None,
430            },
431            providers: HashMap::new(),
432        };
433
434        let result = resolve_config(&options, &config);
435
436        assert!(result.is_err());
437        assert!(result.unwrap_err().to_string().contains("provider"));
438    }
439
440    #[test]
441    fn test_resolve_config_provider_not_found() {
442        let mut options = create_test_options();
443        options.provider = Some("nonexistent".to_string());
444
445        let config = create_test_config();
446
447        let result = resolve_config(&options, &config);
448
449        assert!(result.is_err());
450        assert!(result.unwrap_err().to_string().contains("not found"));
451    }
452
453    #[test]
454    fn test_resolve_config_missing_model() {
455        let mut options = create_test_options();
456        options.model = None;
457
458        let mut config = create_test_config();
459        config.tl.model = None;
460
461        let result = resolve_config(&options, &config);
462
463        assert!(result.is_err());
464        assert!(result.unwrap_err().to_string().contains("model"));
465    }
466
467    #[test]
468    fn test_resolve_config_missing_target_language() {
469        let mut options = create_test_options();
470        options.to = None;
471
472        let mut config = create_test_config();
473        config.tl.to = None;
474
475        let result = resolve_config(&options, &config);
476
477        assert!(result.is_err());
478        assert!(result.unwrap_err().to_string().contains("to"));
479    }
480
481    #[test]
482    fn test_resolve_config_api_key_required_but_missing() {
483        let mut options = create_test_options();
484        options.provider = Some("openrouter".to_string());
485
486        let config = create_test_config();
487
488        let result = resolve_config(&options, &config);
489
490        assert!(result.is_err());
491        assert!(result.unwrap_err().to_string().contains("API key"));
492    }
493}