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