1use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::{Arc, Mutex};
9
10use anyhow::{anyhow, Context, Result};
11use crate::client::RommClient;
12use crate::core::utils;
13use crate::types::Rom;
14
15pub fn resolve_download_directory(configured_download_dir: Option<&str>) -> Result<PathBuf> {
17 let env_override = std::env::var("ROMM_ROMS_DIR")
18 .ok()
19 .or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
20 resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
21}
22
23pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
25 resolve_download_directory_from_inputs(Some(configured_download_dir), None)
26}
27
28pub fn download_directory() -> PathBuf {
30 std::env::var("ROMM_ROMS_DIR")
31 .or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
32 .map(PathBuf::from)
33 .unwrap_or_else(|_| PathBuf::from("./downloads"))
34}
35
36fn resolve_download_directory_from_inputs(
37 configured_download_dir: Option<&str>,
38 env_override: Option<&str>,
39) -> Result<PathBuf> {
40 let raw = env_override.or(configured_download_dir).map(str::trim).ok_or_else(|| {
41 anyhow!("ROMs directory is not configured. Run setup to set a ROMs path.")
42 })?;
43
44 if raw.is_empty() {
45 return Err(anyhow!("ROMs directory cannot be empty"));
46 }
47
48 let input_path = PathBuf::from(raw);
49 let normalized = if input_path.is_relative() {
50 std::env::current_dir()
51 .context("Could not resolve current working directory")?
52 .join(input_path)
53 } else {
54 input_path
55 };
56
57 if normalized.exists() && !normalized.is_dir() {
58 return Err(anyhow!(
59 "Download path is not a directory: {}",
60 normalized.display()
61 ));
62 }
63
64 std::fs::create_dir_all(&normalized)
65 .with_context(|| format!("Could not create download directory {}", normalized.display()))?;
66
67 let probe_name = format!(
68 ".romm-write-test-{}",
69 std::time::SystemTime::now()
70 .duration_since(std::time::UNIX_EPOCH)
71 .unwrap_or_default()
72 .as_nanos()
73 );
74 let probe_path = normalized.join(probe_name);
75 let probe = std::fs::OpenOptions::new()
76 .write(true)
77 .create_new(true)
78 .open(&probe_path)
79 .with_context(|| {
80 format!(
81 "ROMs directory is not writable: {}",
82 normalized.display()
83 )
84 })?;
85 drop(probe);
86 let _ = std::fs::remove_file(&probe_path);
87
88 Ok(normalized)
89}
90
91pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
93 let mut n = 1u32;
94 loop {
95 let name = if n == 1 {
96 format!("{}.zip", stem)
97 } else {
98 format!("{}__{}.zip", stem, n)
99 };
100 let p = dir.join(name);
101 if !p.exists() {
102 return p;
103 }
104 n = n.saturating_add(1);
105 }
106}
107
108#[derive(Debug, Clone)]
114pub enum DownloadStatus {
115 Downloading,
116 Done,
117 SkippedAlreadyExists,
118 FinalizeFailed(String),
119 Error(String),
120}
121
122#[derive(Debug, Clone)]
124pub struct DownloadJob {
125 pub id: usize,
126 pub rom_id: u64,
127 pub name: String,
128 pub platform: String,
129 pub progress: f64,
131 pub status: DownloadStatus,
132}
133
134static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
135
136impl DownloadJob {
137 pub fn new(rom_id: u64, name: String, platform: String) -> Self {
139 Self {
140 id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
141 rom_id,
142 name,
143 platform,
144 progress: 0.0,
145 status: DownloadStatus::Downloading,
146 }
147 }
148
149 pub fn percent(&self) -> u16 {
151 (self.progress * 100.0).round().min(100.0) as u16
152 }
153}
154
155#[derive(Clone)]
163pub struct DownloadManager {
164 jobs: Arc<Mutex<Vec<DownloadJob>>>,
165}
166
167impl Default for DownloadManager {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173impl DownloadManager {
174 pub fn new() -> Self {
175 Self {
176 jobs: Arc::new(Mutex::new(Vec::new())),
177 }
178 }
179
180 pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
182 self.jobs.clone()
183 }
184
185 pub fn start_download(
190 &self,
191 rom: &Rom,
192 client: RommClient,
193 configured_download_dir: Option<&str>,
194 ) -> Result<()> {
195 let platform = rom
196 .platform_display_name
197 .as_deref()
198 .or(rom.platform_custom_name.as_deref())
199 .unwrap_or("—")
200 .to_string();
201
202 let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
203 let job_id = job.id;
204 let rom_id = rom.id;
205 let fs_name = rom.fs_name.clone();
206 let final_console_slug = rom
207 .platform_fs_slug
208 .clone()
209 .or_else(|| rom.platform_slug.clone())
210 .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
211 let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
212 match self.jobs.lock() {
213 Ok(mut jobs) => jobs.push(job),
214 Err(err) => {
215 eprintln!("warning: download job list lock poisoned: {}", err);
216 return Err(anyhow!("download job list lock poisoned: {err}"));
217 }
218 }
219
220 let save_dir = resolve_download_directory(configured_download_dir)?;
221 let jobs = self.jobs.clone();
222 tokio::spawn(async move {
223 let temp_root = save_dir.join(".tmp");
224 if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
225 if let Ok(mut list) = jobs.lock() {
226 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
227 j.status = DownloadStatus::Error(format!(
228 "Could not create temp directory {}: {err}",
229 temp_root.display()
230 ));
231 }
232 }
233 return;
234 }
235
236 let console_dir = save_dir.join(utils::sanitize_filename(&final_console_slug));
237 let final_path = console_dir.join(final_name.clone());
238 if let Err(err) = tokio::fs::create_dir_all(&console_dir).await {
239 if let Ok(mut list) = jobs.lock() {
240 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
241 j.status = DownloadStatus::Error(format!(
242 "Could not create console directory {}: {err}",
243 console_dir.display()
244 ));
245 }
246 }
247 return;
248 }
249
250 if final_path.exists() {
251 if let Ok(mut list) = jobs.lock() {
252 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
253 j.status = DownloadStatus::SkippedAlreadyExists;
254 j.progress = 1.0;
255 }
256 }
257 return;
258 }
259
260 let temp_name = format!(
261 "rom-{}-{}-{}.part",
262 rom_id,
263 utils::sanitize_filename(&fs_name),
264 job_id
265 );
266 let temp_path = temp_root.join(temp_name);
267
268 let on_progress = |received: u64, total: u64| {
269 let p = if total > 0 {
270 received as f64 / total as f64
271 } else {
272 0.0
273 };
274
275 if let Ok(mut list) = jobs.lock() {
276 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
277 j.progress = p;
278 }
279 }
280 };
281
282 let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
283 if download_result.is_err() {
284 let _ = tokio::fs::remove_file(&temp_path).await;
285 }
286 match download_result {
287 Ok(()) => {
288 match finalize_download(&temp_path, &final_path).await {
289 Ok(FinalizeResult::Done) => {
290 if let Ok(mut list) = jobs.lock() {
291 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
292 j.status = DownloadStatus::Done;
293 j.progress = 1.0;
294 }
295 }
296 }
297 Ok(FinalizeResult::SkippedAlreadyExists) => {
298 if let Ok(mut list) = jobs.lock() {
299 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
300 j.status = DownloadStatus::SkippedAlreadyExists;
301 j.progress = 1.0;
302 }
303 }
304 }
305 Err(err) => {
306 let _ = tokio::fs::remove_file(&temp_path).await;
307 if let Ok(mut list) = jobs.lock() {
308 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
309 j.status = DownloadStatus::FinalizeFailed(err.to_string());
310 }
311 }
312 }
313 }
314 }
315 Err(e) => {
316 if let Ok(mut list) = jobs.lock() {
317 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
318 j.status = DownloadStatus::Error(e.to_string());
319 }
320 }
321 }
322 }
323 });
324 Ok(())
325 }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329enum FinalizeResult {
330 Done,
331 SkippedAlreadyExists,
332}
333
334async fn finalize_download(temp_path: &Path, final_path: &Path) -> Result<FinalizeResult> {
335 if final_path.exists() {
336 let _ = tokio::fs::remove_file(temp_path).await;
337 return Ok(FinalizeResult::SkippedAlreadyExists);
338 }
339
340 match tokio::fs::rename(temp_path, final_path).await {
341 Ok(()) => Ok(FinalizeResult::Done),
342 Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
343 tokio::fs::copy(temp_path, final_path).await.with_context(|| {
344 format!(
345 "Could not copy temp ROM {} to final destination {}",
346 temp_path.display(),
347 final_path.display()
348 )
349 })?;
350 let file = tokio::fs::File::open(final_path).await.with_context(|| {
351 format!("Could not open finalized ROM for sync: {}", final_path.display())
352 })?;
353 file.sync_all().await.with_context(|| {
354 format!("Could not sync finalized ROM to disk: {}", final_path.display())
355 })?;
356 tokio::fs::remove_file(temp_path).await.with_context(|| {
357 format!("Could not remove temp ROM after copy: {}", temp_path.display())
358 })?;
359 Ok(FinalizeResult::Done)
360 }
361 Err(rename_err) => Err(anyhow!(
362 "Could not move temp ROM {} to final destination {}: {}",
363 temp_path.display(),
364 final_path.display(),
365 rename_err
366 )),
367 }
368}
369
370fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
371 matches!(err.raw_os_error(), Some(18) | Some(17))
372}
373
374fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
375 let sanitized = utils::sanitize_filename(fs_name);
376 if sanitized.trim().is_empty() {
377 format!("rom-{rom_id}.zip")
378 } else {
379 sanitized
380 }
381}
382
383#[cfg(test)]
384fn final_download_path_for_rom(roms_dir: &Path, rom: &Rom) -> PathBuf {
385 let platform_slug = rom
386 .platform_fs_slug
387 .clone()
388 .or_else(|| rom.platform_slug.clone())
389 .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
390 let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
391 console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use std::io::Write;
398 use std::time::{SystemTime, UNIX_EPOCH};
399 use crate::types::Rom;
400
401 fn rom_fixture_with_platform(platform_fs_slug: Option<&str>, fs_name: &str) -> Rom {
402 Rom {
403 id: 42,
404 platform_id: 7,
405 platform_slug: Some("nintendo-switch".to_string()),
406 platform_fs_slug: platform_fs_slug.map(ToString::to_string),
407 platform_custom_name: None,
408 platform_display_name: None,
409 fs_name: fs_name.to_string(),
410 fs_name_no_tags: "game".to_string(),
411 fs_name_no_ext: "game".to_string(),
412 fs_extension: "zip".to_string(),
413 fs_path: "/game.zip".to_string(),
414 fs_size_bytes: 1,
415 name: "Game".to_string(),
416 slug: None,
417 summary: None,
418 path_cover_small: None,
419 path_cover_large: None,
420 url_cover: None,
421 is_unidentified: false,
422 is_identified: true,
423 }
424 }
425
426 #[test]
427 fn unique_zip_path_skips_existing_files() {
428 let ts = SystemTime::now()
429 .duration_since(UNIX_EPOCH)
430 .unwrap()
431 .as_nanos();
432 let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
433 std::fs::create_dir_all(&dir).unwrap();
434 let p1 = dir.join("game.zip");
435 std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
436 let p2 = unique_zip_path(&dir, "game");
437 assert_eq!(p2.file_name().unwrap(), "game__2.zip");
438 let _ = std::fs::remove_file(&p1);
439 let _ = std::fs::remove_dir(&dir);
440 }
441
442 #[test]
443 fn resolve_download_directory_rejects_empty_configured_path() {
444 let err = resolve_download_directory_from_inputs(Some(" "), None)
445 .expect_err("empty configured path should be rejected");
446 assert!(
447 err.to_string().contains("cannot be empty"),
448 "unexpected error: {err:#}"
449 );
450 }
451
452 #[test]
453 fn resolve_download_directory_creates_missing_nested_directory() {
454 let ts = SystemTime::now()
455 .duration_since(UNIX_EPOCH)
456 .unwrap()
457 .as_nanos();
458 let base = std::env::temp_dir().join(format!("romm-dl-resolve-{ts}"));
459 let nested = base.join("a").join("b").join("c");
460 let nested_str = nested.to_string_lossy().to_string();
461
462 let resolved = resolve_download_directory_from_inputs(Some(&nested_str), None)
463 .expect("expected missing directory to be created");
464
465 assert!(resolved.is_dir(), "resolved path must be a directory");
466 assert!(nested.is_dir(), "nested path should be created");
467 let _ = std::fs::remove_dir_all(&base);
468 }
469
470 #[test]
471 fn resolve_download_directory_fails_when_target_is_a_file() {
472 let ts = SystemTime::now()
473 .duration_since(UNIX_EPOCH)
474 .unwrap()
475 .as_nanos();
476 let base = std::env::temp_dir().join(format!("romm-dl-file-target-{ts}"));
477 std::fs::create_dir_all(&base).expect("create base dir");
478 let file_path = base.join("not-a-dir.txt");
479 std::fs::write(&file_path, b"x").expect("create file");
480 let input = file_path.to_string_lossy().to_string();
481
482 let err = resolve_download_directory_from_inputs(Some(&input), None)
483 .expect_err("file target must fail");
484 assert!(
485 err.to_string().contains("not a directory"),
486 "unexpected error: {err:#}"
487 );
488
489 let _ = std::fs::remove_file(&file_path);
490 let _ = std::fs::remove_dir_all(&base);
491 }
492
493 #[test]
494 fn resolve_download_directory_env_override_takes_precedence() {
495 let ts = SystemTime::now()
496 .duration_since(UNIX_EPOCH)
497 .unwrap()
498 .as_nanos();
499 let configured = std::env::temp_dir().join(format!("romm-dl-configured-{ts}"));
500 let env_dir = std::env::temp_dir().join(format!("romm-dl-env-{ts}"));
501 let configured_str = configured.to_string_lossy().to_string();
502 let env_str = env_dir.to_string_lossy().to_string();
503
504 let resolved = resolve_download_directory_from_inputs(Some(&configured_str), Some(&env_str))
505 .expect("env override should be used");
506
507 assert_eq!(resolved, env_dir);
508 assert!(env_dir.is_dir(), "env directory should be created");
509 assert!(
510 !configured.is_dir(),
511 "configured path should be ignored when env override is set"
512 );
513 let _ = std::fs::remove_dir_all(&env_dir);
514 }
515
516 #[test]
517 fn final_download_path_uses_console_folder_and_original_file_name() {
518 let rom = rom_fixture_with_platform(Some("switch"), "Zelda (USA).xci");
519 let base = PathBuf::from("/roms");
520 let out = final_download_path_for_rom(&base, &rom);
521 assert_eq!(out, PathBuf::from("/roms/switch/Zelda _USA_.xci"));
522 }
523
524 #[tokio::test]
525 async fn finalize_download_skips_when_final_exists() {
526 let ts = SystemTime::now()
527 .duration_since(UNIX_EPOCH)
528 .unwrap()
529 .as_nanos();
530 let base = std::env::temp_dir().join(format!("romm-finalize-skip-{ts}"));
531 std::fs::create_dir_all(&base).unwrap();
532 let temp = base.join("temp.part");
533 let final_path = base.join("final.zip");
534 std::fs::write(&temp, b"temp").unwrap();
535 std::fs::write(&final_path, b"existing").unwrap();
536
537 let result = finalize_download(&temp, &final_path).await.unwrap();
538 assert_eq!(result, super::FinalizeResult::SkippedAlreadyExists);
539 assert!(
540 !temp.exists(),
541 "temp file should be removed when final destination exists"
542 );
543
544 let _ = std::fs::remove_file(&final_path);
545 let _ = std::fs::remove_dir_all(&base);
546 }
547}