1use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct LogPaths {
35 player_log: PathBuf,
37 player_prev_log: PathBuf,
39}
40
41impl LogPaths {
42 pub fn player_log(&self) -> &Path {
44 &self.player_log
45 }
46
47 pub fn player_prev_log(&self) -> &Path {
49 &self.player_prev_log
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum DiscoveryError {
60 #[error("could not resolve platform log directory")]
66 BaseDirNotFound,
67
68 #[error("log file not found at {path}", path = path.display())]
73 LogFileMissing {
74 path: PathBuf,
76 },
77
78 #[error("unsupported platform for log file discovery")]
83 UnsupportedPlatform,
84}
85
86const MTGA_LOG_DIR: &[&str] = &["Wizards Of The Coast", "MTGA"];
92
93const PLAYER_LOG: &str = "Player.log";
95
96const PLAYER_PREV_LOG: &str = "Player-prev.log";
98
99#[cfg(target_os = "windows")]
107fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
108 known_folders::get_known_folder_path(known_folders::KnownFolder::LocalAppDataLow)
109 .ok_or(DiscoveryError::BaseDirNotFound)
110}
111
112#[cfg(target_os = "macos")]
116fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
117 std::env::var("HOME")
118 .ok()
119 .map(|home| PathBuf::from(home).join("Library").join("Logs"))
120 .ok_or(DiscoveryError::BaseDirNotFound)
121}
122
123#[cfg(target_os = "linux")]
134fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
135 use crate::log::steam::{steam_library_for_appid, MTGA_APP_ID};
136
137 let home = std::env::var_os("HOME")
138 .map(PathBuf::from)
139 .ok_or(DiscoveryError::BaseDirNotFound)?;
140
141 let steam_lib = steam_library_for_appid(MTGA_APP_ID);
142 let candidates = linux_candidate_base_dirs(&home, steam_lib.as_deref());
143
144 let mtga_tail = Path::new("Wizards Of The Coast")
145 .join("MTGA")
146 .join("Player.log");
147
148 for candidate in &candidates {
149 if candidate.join(&mtga_tail).exists() {
150 ::log::info!("Linux: discovered MTGA base dir: {}", candidate.display());
151 return Ok(candidate.clone());
152 }
153 }
154
155 let lutris_locallow = home
161 .join("Games")
162 .join("magic-the-gathering-arena")
163 .join("drive_c")
164 .join("users")
165 .join("steamuser")
166 .join("AppData")
167 .join("LocalLow");
168 let missing_path = candidates.into_iter().last().map_or_else(
169 || lutris_locallow.join(&mtga_tail),
170 |base| base.join(&mtga_tail),
171 );
172
173 ::log::warn!(
174 "Linux: no MTGA Player.log found; last checked: {}",
175 missing_path.display()
176 );
177 Err(DiscoveryError::LogFileMissing { path: missing_path })
178}
179
180#[cfg(target_os = "linux")]
186pub(crate) fn linux_candidate_base_dirs(home: &Path, steam_lib: Option<&Path>) -> Vec<PathBuf> {
187 let mut candidates = Vec::new();
188
189 if let Some(library) = steam_lib {
191 let locallow = library
192 .join("steamapps")
193 .join("compatdata")
194 .join("2141910")
195 .join("pfx")
196 .join("drive_c")
197 .join("users")
198 .join("steamuser")
199 .join("AppData")
200 .join("LocalLow");
201 candidates.push(locallow);
202 }
203
204 let lutris_locallow = home
206 .join("Games")
207 .join("magic-the-gathering-arena")
208 .join("drive_c")
209 .join("users")
210 .join("steamuser")
211 .join("AppData")
212 .join("LocalLow");
213 candidates.push(lutris_locallow);
214
215 candidates
216}
217
218#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
220fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
221 Err(DiscoveryError::UnsupportedPlatform)
222}
223
224fn build_log_paths(base_dir: PathBuf) -> LogPaths {
232 let mut mtga_dir = base_dir;
233 for component in MTGA_LOG_DIR {
234 mtga_dir.push(component);
235 }
236 LogPaths {
237 player_log: mtga_dir.join(PLAYER_LOG),
238 player_prev_log: mtga_dir.join(PLAYER_PREV_LOG),
239 }
240}
241
242fn check_existence(paths: LogPaths) -> Result<LogPaths, DiscoveryError> {
247 if paths.player_log.exists() {
248 ::log::info!("discovered log file: {}", paths.player_log.display());
249 Ok(paths)
250 } else {
251 ::log::warn!("log file not found: {}", paths.player_log.display());
252 Err(DiscoveryError::LogFileMissing {
253 path: paths.player_log,
254 })
255 }
256}
257
258pub fn resolve_log_paths() -> Result<LogPaths, DiscoveryError> {
281 let base_dir = resolve_base_dir()?;
282 Ok(build_log_paths(base_dir))
283}
284
285pub fn discover_log_file() -> Result<LogPaths, DiscoveryError> {
301 let paths = resolve_log_paths()?;
302 check_existence(paths)
303}
304
305#[cfg(test)]
310mod tests {
311 use super::*;
312 use std::fs;
313
314 type TestResult = Result<(), Box<dyn std::error::Error>>;
315
316 #[test]
319 fn test_build_log_paths_appends_mtga_components() {
320 let base = PathBuf::from("/some/base");
321 let paths = build_log_paths(base);
322 assert_eq!(
323 paths.player_log(),
324 Path::new("/some/base/Wizards Of The Coast/MTGA/Player.log")
325 );
326 assert_eq!(
327 paths.player_prev_log(),
328 Path::new("/some/base/Wizards Of The Coast/MTGA/Player-prev.log")
329 );
330 }
331
332 #[test]
333 fn test_build_log_paths_windows_style_path() {
334 let base = PathBuf::from(r"C:\Users\User\AppData\LocalLow");
335 let paths = build_log_paths(base);
336
337 let log_str = paths.player_log().to_string_lossy();
340 assert!(log_str.contains("Wizards Of The Coast"));
341 assert!(log_str.contains("MTGA"));
342 assert!(log_str.ends_with("Player.log"));
343 }
344
345 #[test]
346 fn test_build_log_paths_macos_style_path() {
347 let base = PathBuf::from("/Users/player/Library/Logs");
348 let paths = build_log_paths(base);
349 assert_eq!(
350 paths.player_log(),
351 Path::new("/Users/player/Library/Logs/Wizards Of The Coast/MTGA/Player.log")
352 );
353 }
354
355 #[test]
356 fn test_build_log_paths_both_files_share_directory() {
357 let paths = build_log_paths(PathBuf::from("/base"));
358 assert_eq!(
359 paths.player_log().parent(),
360 paths.player_prev_log().parent()
361 );
362 }
363
364 #[test]
365 fn test_build_log_paths_player_prev_log_correct_name() {
366 let paths = build_log_paths(PathBuf::from("/base"));
367 let filename = paths
368 .player_prev_log()
369 .file_name()
370 .map(|f| f.to_string_lossy().into_owned())
371 .unwrap_or_default();
372 assert_eq!(filename, "Player-prev.log");
373 }
374
375 #[test]
376 fn test_build_log_paths_player_log_correct_name() {
377 let paths = build_log_paths(PathBuf::from("/base"));
378 let filename = paths
379 .player_log()
380 .file_name()
381 .map(|f| f.to_string_lossy().into_owned())
382 .unwrap_or_default();
383 assert_eq!(filename, "Player.log");
384 }
385
386 #[test]
389 fn test_log_paths_clone_is_equal() {
390 let paths = build_log_paths(PathBuf::from("/base"));
391 let cloned = paths.clone();
392 assert_eq!(paths, cloned);
393 }
394
395 #[test]
398 fn test_check_existence_found_returns_ok() -> TestResult {
399 let dir = tempfile::tempdir()?;
400 let mtga_dir = dir.path().join("Wizards Of The Coast").join("MTGA");
401 fs::create_dir_all(&mtga_dir)?;
402 fs::write(mtga_dir.join("Player.log"), "test log data")?;
403
404 let paths = build_log_paths(dir.path().to_path_buf());
405 let result = check_existence(paths);
406 assert!(result.is_ok());
407 Ok(())
408 }
409
410 #[test]
411 fn test_check_existence_found_returns_correct_paths() -> TestResult {
412 let dir = tempfile::tempdir()?;
413 let mtga_dir = dir.path().join("Wizards Of The Coast").join("MTGA");
414 fs::create_dir_all(&mtga_dir)?;
415 fs::write(mtga_dir.join("Player.log"), "data")?;
416
417 let paths = build_log_paths(dir.path().to_path_buf());
418 let found = check_existence(paths)?;
419 assert_eq!(found.player_log(), mtga_dir.join("Player.log"));
420 assert_eq!(found.player_prev_log(), mtga_dir.join("Player-prev.log"));
421 Ok(())
422 }
423
424 #[test]
425 fn test_check_existence_missing_returns_log_file_missing() -> TestResult {
426 let dir = tempfile::tempdir()?;
427 let paths = build_log_paths(dir.path().to_path_buf());
429 let result = check_existence(paths);
430 assert!(matches!(result, Err(DiscoveryError::LogFileMissing { .. })));
431 Ok(())
432 }
433
434 #[test]
435 fn test_check_existence_missing_error_contains_expected_path() -> TestResult {
436 let dir = tempfile::tempdir()?;
437 let paths = build_log_paths(dir.path().to_path_buf());
438 let expected_path = paths.player_log().to_path_buf();
439
440 match check_existence(paths) {
441 Err(DiscoveryError::LogFileMissing { path }) => {
442 assert_eq!(path, expected_path);
443 }
444 other => return Err(format!("expected LogFileMissing, got: {other:?}").into()),
445 }
446 Ok(())
447 }
448
449 #[test]
450 fn test_check_existence_directory_exists_but_no_file() -> TestResult {
451 let dir = tempfile::tempdir()?;
452 let mtga_dir = dir.path().join("Wizards Of The Coast").join("MTGA");
453 fs::create_dir_all(&mtga_dir)?;
454 let paths = build_log_paths(dir.path().to_path_buf());
457 let result = check_existence(paths);
458 assert!(matches!(result, Err(DiscoveryError::LogFileMissing { .. })));
459 Ok(())
460 }
461
462 #[test]
465 fn test_discovery_error_base_dir_not_found_display() {
466 let err = DiscoveryError::BaseDirNotFound;
467 assert_eq!(err.to_string(), "could not resolve platform log directory");
468 }
469
470 #[test]
471 fn test_discovery_error_unsupported_platform_display() {
472 let err = DiscoveryError::UnsupportedPlatform;
473 assert_eq!(
474 err.to_string(),
475 "unsupported platform for log file discovery"
476 );
477 }
478
479 #[test]
480 fn test_discovery_error_log_file_missing_display() {
481 let err = DiscoveryError::LogFileMissing {
482 path: PathBuf::from("/some/path/Player.log"),
483 };
484 let display = err.to_string();
485 assert!(display.contains("/some/path/Player.log"));
486 assert!(display.contains("log file not found"));
487 }
488
489 #[test]
492 fn test_discovery_error_clone_is_equal() {
493 let err = DiscoveryError::LogFileMissing {
494 path: PathBuf::from("/test"),
495 };
496 let cloned = err.clone();
497 assert_eq!(err, cloned);
498 }
499
500 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
503 #[test]
504 fn test_resolve_log_paths_unsupported_platform() {
505 let result = resolve_log_paths();
506 assert!(matches!(result, Err(DiscoveryError::UnsupportedPlatform)));
507 }
508
509 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
510 #[test]
511 fn test_discover_log_file_unsupported_platform() {
512 let result = discover_log_file();
513 assert!(matches!(result, Err(DiscoveryError::UnsupportedPlatform)));
514 }
515
516 #[cfg(target_os = "linux")]
519 mod linux_discovery {
520 use super::*;
521 use crate::log::discovery::linux_candidate_base_dirs;
522 use std::fs;
523
524 type TestResult = Result<(), Box<dyn std::error::Error>>;
525
526 fn player_log_path(locallow: &Path) -> PathBuf {
528 locallow
529 .join("Wizards Of The Coast")
530 .join("MTGA")
531 .join("Player.log")
532 }
533
534 #[test]
535 fn test_linux_candidate_base_dirs_steam_first_lutris_second() {
536 let home = PathBuf::from("/home/testuser");
537 let steam_lib = PathBuf::from("/mnt/games/SteamLibrary");
538 let candidates = linux_candidate_base_dirs(&home, Some(&steam_lib));
539
540 assert_eq!(candidates.len(), 2);
541 assert!(candidates[0]
543 .to_string_lossy()
544 .contains("steamapps/compatdata"));
545 assert!(candidates[0].to_string_lossy().contains("2141910"));
546 assert!(candidates[1]
548 .to_string_lossy()
549 .contains("Games/magic-the-gathering-arena"));
550 }
551
552 #[test]
553 fn test_linux_candidate_base_dirs_no_steam_only_lutris() {
554 let home = PathBuf::from("/home/testuser");
555 let candidates = linux_candidate_base_dirs(&home, None);
556
557 assert_eq!(candidates.len(), 1);
558 assert!(candidates[0]
559 .to_string_lossy()
560 .contains("Games/magic-the-gathering-arena"));
561 }
562
563 #[test]
564 fn test_linux_resolve_base_dir_steam_found() -> TestResult {
565 let tmp = tempfile::tempdir()?;
566 let steam_lib = tmp.path().join("SteamLibrary");
567 let locallow = steam_lib
568 .join("steamapps")
569 .join("compatdata")
570 .join("2141910")
571 .join("pfx")
572 .join("drive_c")
573 .join("users")
574 .join("steamuser")
575 .join("AppData")
576 .join("LocalLow");
577 let mtga_dir = locallow.join("Wizards Of The Coast").join("MTGA");
578 fs::create_dir_all(&mtga_dir)?;
579 fs::write(mtga_dir.join("Player.log"), "test")?;
580
581 let candidates = linux_candidate_base_dirs(tmp.path(), Some(&steam_lib));
582 let mtga_tail = Path::new("Wizards Of The Coast")
583 .join("MTGA")
584 .join("Player.log");
585
586 let found = candidates.iter().find(|c| c.join(&mtga_tail).exists());
587 assert!(found.is_some(), "Steam candidate should be found");
588 assert_eq!(found, Some(&locallow));
589 Ok(())
590 }
591
592 #[test]
593 fn test_linux_resolve_base_dir_lutris_fallback() -> TestResult {
594 let tmp = tempfile::tempdir()?;
595 let lutris_locallow = tmp
597 .path()
598 .join("Games")
599 .join("magic-the-gathering-arena")
600 .join("drive_c")
601 .join("users")
602 .join("steamuser")
603 .join("AppData")
604 .join("LocalLow");
605 let mtga_dir = lutris_locallow.join("Wizards Of The Coast").join("MTGA");
606 fs::create_dir_all(&mtga_dir)?;
607 fs::write(mtga_dir.join("Player.log"), "test")?;
608
609 let candidates = linux_candidate_base_dirs(tmp.path(), None);
610 let mtga_tail = Path::new("Wizards Of The Coast")
611 .join("MTGA")
612 .join("Player.log");
613
614 let found = candidates.iter().find(|c| c.join(&mtga_tail).exists());
615 assert!(found.is_some(), "Lutris candidate should be found");
616 assert_eq!(found, Some(&lutris_locallow));
617 Ok(())
618 }
619
620 #[test]
621 fn test_linux_candidate_base_dirs_nothing_found_no_candidates_match() -> TestResult {
622 let tmp = tempfile::tempdir()?;
623 let steam_lib = tmp.path().join("SteamLibrary");
625 let candidates = linux_candidate_base_dirs(tmp.path(), Some(&steam_lib));
626 let mtga_tail = Path::new("Wizards Of The Coast")
627 .join("MTGA")
628 .join("Player.log");
629
630 let found = candidates.iter().find(|c| c.join(&mtga_tail).exists());
631 assert!(
632 found.is_none(),
633 "No candidate should match when files don't exist"
634 );
635 Ok(())
636 }
637
638 #[test]
639 fn test_linux_candidate_steam_path_contains_correct_appid() {
640 let home = PathBuf::from("/home/user");
641 let steam_lib = PathBuf::from("/home/user/.local/share/Steam");
642 let candidates = linux_candidate_base_dirs(&home, Some(&steam_lib));
643
644 assert!(!candidates.is_empty());
645 let steam_candidate = &candidates[0];
646 assert!(
647 steam_candidate.to_string_lossy().contains("2141910"),
648 "Steam candidate must use MTGA app id 2141910: {}",
649 steam_candidate.display()
650 );
651 }
652
653 #[test]
654 fn test_linux_player_log_path_helper_appends_correctly() {
655 let base = PathBuf::from("/base/LocalLow");
656 let log_path = player_log_path(&base);
657 assert_eq!(
658 log_path,
659 PathBuf::from("/base/LocalLow/Wizards Of The Coast/MTGA/Player.log")
660 );
661 }
662 }
663
664 #[cfg(target_os = "windows")]
665 #[test]
666 fn test_resolve_log_paths_windows_contains_locallow() -> TestResult {
667 let paths = resolve_log_paths()?;
668 let log_str = paths.player_log().to_string_lossy();
669 assert!(
670 log_str.contains("LocalLow"),
671 "Windows path should contain LocalLow: {log_str}"
672 );
673 Ok(())
674 }
675
676 #[cfg(target_os = "macos")]
677 #[test]
678 fn test_resolve_log_paths_macos_contains_library_logs() -> TestResult {
679 let paths = resolve_log_paths()?;
680 let log_str = paths.player_log().to_string_lossy();
681 assert!(
682 log_str.contains("Library/Logs"),
683 "macOS path should contain Library/Logs: {log_str}"
684 );
685 Ok(())
686 }
687
688 #[test]
691 fn test_discover_log_file_returns_error_in_ci() {
692 let result = discover_log_file();
696 assert!(result.is_err());
697 }
698}