tl_cli/cli/commands/
translate.rs

1use anyhow::{Result, bail};
2use futures_util::StreamExt;
3use std::io::{self, Write};
4
5use super::load_config;
6use crate::cache::CacheManager;
7use crate::config::{ResolveOptions, resolve_config};
8use crate::fs::atomic_write;
9use crate::input::InputReader;
10use crate::translation::{TranslationClient, TranslationRequest};
11use crate::ui::Spinner;
12
13/// Options for the translate command.
14pub struct TranslateOptions {
15    /// Input file path (reads from stdin if `None`).
16    pub file: Option<String>,
17    /// Target language code.
18    pub to: Option<String>,
19    /// Provider name.
20    pub provider: Option<String>,
21    /// Model name.
22    pub model: Option<String>,
23    /// Translation style.
24    pub style: Option<String>,
25    /// Whether to bypass the cache.
26    pub no_cache: bool,
27    /// Whether to overwrite the input file with the translation.
28    pub write: bool,
29}
30
31/// Runs the translate command.
32///
33/// Translates input from a file or stdin and outputs the result.
34/// Supports caching and streaming output.
35pub async fn run_translate(options: TranslateOptions) -> Result<()> {
36    // Validate -w option requires a file
37    if options.write && options.file.is_none() {
38        bail!("--write requires a file argument (cannot write to stdin)");
39    }
40
41    let (_manager, config_file) = load_config()?;
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}