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