tl_cli/cli/commands/
translate.rs

1use anyhow::{Result, bail};
2use futures_util::StreamExt;
3use std::io::{self, Write};
4
5use crate::cache::CacheManager;
6use crate::config::{ConfigManager, ResolveOptions, resolve_config};
7use crate::fs::atomic_write;
8use crate::input::InputReader;
9use crate::translation::{TranslationClient, TranslationRequest};
10use crate::ui::Spinner;
11
12/// Options for the translate command.
13pub struct TranslateOptions {
14    /// Input file path (reads from stdin if `None`).
15    pub file: Option<String>,
16    /// Target language code.
17    pub to: Option<String>,
18    /// Provider name.
19    pub provider: Option<String>,
20    /// Model name.
21    pub model: Option<String>,
22    /// Translation style.
23    pub style: Option<String>,
24    /// Whether to bypass the cache.
25    pub no_cache: bool,
26    /// Whether to overwrite the input file with the translation.
27    pub write: bool,
28}
29
30/// Runs the translate command.
31///
32/// Translates input from a file or stdin and outputs the result.
33/// Supports caching and streaming output.
34pub async fn run_translate(options: TranslateOptions) -> Result<()> {
35    // Validate -w option requires a file
36    if options.write && options.file.is_none() {
37        bail!("--write requires a file argument (cannot write to stdin)");
38    }
39
40    let manager = ConfigManager::new()?;
41    let config_file = manager.load_or_default();
42    let resolve_options = ResolveOptions {
43        to: options.to.clone(),
44        provider: options.provider.clone(),
45        model: options.model.clone(),
46        style: options.style.clone(),
47    };
48    let resolved = resolve_config(&resolve_options, &config_file)?;
49
50    let source_text = InputReader::read(options.file.as_deref())?;
51
52    if source_text.is_empty() {
53        bail!("Input is empty");
54    }
55
56    let cache_manager = CacheManager::new()?;
57
58    // Create request first, moving values where possible
59    // Only endpoint needs clone (used by both client and request)
60    let request = TranslationRequest {
61        source_text,
62        target_language: resolved.target_language,
63        model: resolved.model,
64        endpoint: resolved.endpoint.clone(),
65        style: resolved.style_prompt,
66    };
67
68    // Create client with remaining values (endpoint cloned, api_key moved)
69    let client = TranslationClient::new(resolved.endpoint, resolved.api_key);
70
71    if !options.no_cache
72        && let Some(cached) = cache_manager.get(&request)?
73    {
74        if options.write {
75            if let Some(ref file_path) = options.file {
76                atomic_write(file_path, &cached)?;
77            }
78        } else {
79            print!("{cached}");
80            io::stdout().flush()?;
81        }
82        return Ok(());
83    }
84
85    let spinner_msg = if options.write {
86        format!(
87            "Translating {}...",
88            options.file.as_deref().unwrap_or("file")
89        )
90    } else {
91        "Translating...".to_string()
92    };
93    let spinner = Spinner::new(&spinner_msg);
94
95    let mut stream = client.translate_stream(&request).await?;
96    let mut full_response = String::new();
97    let mut spinner_active = true;
98
99    while let Some(chunk_result) = stream.next().await {
100        let chunk = chunk_result?;
101
102        // When streaming to stdout, stop spinner on first chunk to show output
103        // When writing to file, keep spinner until completion
104        if spinner_active && !options.write {
105            spinner.stop();
106            spinner_active = false;
107        }
108
109        if !options.write {
110            print!("{chunk}");
111            io::stdout().flush()?;
112        }
113        full_response.push_str(&chunk);
114    }
115
116    if spinner_active {
117        spinner.stop();
118    }
119
120    if !options.write && !full_response.is_empty() {
121        println!();
122    }
123
124    if !options.no_cache && !full_response.is_empty() {
125        cache_manager.put(&request, &full_response)?;
126    }
127
128    // Write to file if -w is specified
129    if options.write
130        && !full_response.is_empty()
131        && let Some(ref file_path) = options.file
132    {
133        atomic_write(file_path, &full_response)?;
134    }
135
136    Ok(())
137}