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
13fn 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 fs::write(&temp_path, content)?;
23
24 fs::rename(&temp_path, file_path)?;
26
27 Ok(())
28}
29
30pub struct TranslateOptions {
32 pub file: Option<String>,
34 pub to: Option<String>,
36 pub provider: Option<String>,
38 pub model: Option<String>,
40 pub no_cache: bool,
42 pub write: bool,
44}
45
46pub async fn run_translate(options: TranslateOptions) -> Result<()> {
51 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 let request = TranslationRequest {
71 source_text,
72 target_language: resolved.target_language,
73 model: resolved.model,
74 endpoint: resolved.endpoint.clone(),
75 };
76
77 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 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 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
148pub fn resolve_config(
157 options: &TranslateOptions,
158 config_file: &ConfigFile,
159) -> Result<ResolvedConfig> {
160 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 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 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 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 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 let api_key = provider_config.get_api_key();
242
243 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 #[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 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 #[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}