Skip to main content

manasight_parser/log/
discovery.rs

1//! Platform-specific log file path resolution.
2//!
3//! Resolves the default location of MTG Arena's `Player.log` on each
4//! supported platform (Windows via `known-folders`, macOS via `~/Library/Logs/`,
5//! Linux via Steam/Proton `libraryfolders.vdf` or Lutris fallback).
6//!
7//! # Usage
8//!
9//! ```rust,no_run
10//! use manasight_parser::log::discovery;
11//!
12//! // Resolve and verify the log file exists:
13//! match discovery::discover_log_file() {
14//!     Ok(paths) => println!("Found: {}", paths.player_log().display()),
15//!     Err(e) => eprintln!("Discovery failed: {e}"),
16//! }
17//! ```
18//!
19//! When [`discover_log_file`] returns [`DiscoveryError::LogFileMissing`],
20//! callers should notify the user (e.g., "MTG Arena not found" or "Enable
21//! Detailed Logging") and poll periodically until the file appears.
22
23use std::path::{Path, PathBuf};
24
25// ---------------------------------------------------------------------------
26// LogPaths
27// ---------------------------------------------------------------------------
28
29/// Resolved paths to MTG Arena log files.
30///
31/// Both files reside in the same directory. `player_prev_log` contains the
32/// previous session's log and is used for catch-up parsing on startup.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct LogPaths {
35    /// Path to the active `Player.log`.
36    player_log: PathBuf,
37    /// Path to the previous session's `Player-prev.log`.
38    player_prev_log: PathBuf,
39}
40
41impl LogPaths {
42    /// Returns the path to `Player.log`.
43    pub fn player_log(&self) -> &Path {
44        &self.player_log
45    }
46
47    /// Returns the path to `Player-prev.log`.
48    pub fn player_prev_log(&self) -> &Path {
49        &self.player_prev_log
50    }
51}
52
53// ---------------------------------------------------------------------------
54// DiscoveryError
55// ---------------------------------------------------------------------------
56
57/// Errors that can occur during log file discovery.
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum DiscoveryError {
60    /// The platform-specific base directory could not be determined.
61    ///
62    /// On Windows this means `KnownFolder::LocalAppDataLow` failed to
63    /// resolve. On macOS this means the `HOME` environment variable is
64    /// not set.
65    #[error("could not resolve platform log directory")]
66    BaseDirNotFound,
67
68    /// The resolved log file path does not exist on disk.
69    ///
70    /// Callers should notify the user (e.g., "MTG Arena not found") and
71    /// poll periodically until the file appears.
72    #[error("log file not found at {path}", path = path.display())]
73    LogFileMissing {
74        /// The expected path that was checked.
75        path: PathBuf,
76    },
77
78    /// The current operating system is not supported.
79    ///
80    /// Only Windows, macOS, and Linux (Steam/Proton via `libraryfolders.vdf`,
81    /// Lutris fallback) are supported targets.
82    #[error("unsupported platform for log file discovery")]
83    UnsupportedPlatform,
84}
85
86// ---------------------------------------------------------------------------
87// Constants
88// ---------------------------------------------------------------------------
89
90/// Subdirectory components appended to the platform base directory.
91const MTGA_LOG_DIR: &[&str] = &["Wizards Of The Coast", "MTGA"];
92
93/// Name of the active log file.
94const PLAYER_LOG: &str = "Player.log";
95
96/// Name of the previous session's log file.
97const PLAYER_PREV_LOG: &str = "Player-prev.log";
98
99// ---------------------------------------------------------------------------
100// Platform-specific base directory resolution
101// ---------------------------------------------------------------------------
102
103/// Resolves the platform base directory for MTGA logs on Windows.
104///
105/// Uses `KnownFolder::LocalAppDataLow` via the `known-folders` crate.
106#[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/// Resolves the platform base directory for MTGA logs on macOS.
113///
114/// Reads the `HOME` environment variable and appends `Library/Logs`.
115#[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/// Resolves the platform base directory for MTGA logs on Linux.
124///
125/// Tries candidates in order (Steam/Proton first, then Lutris) and returns
126/// the `.../LocalLow` base dir for the first candidate whose `Player.log`
127/// exists.  The Linux arm is existence-aware because the base directory
128/// must be discovered dynamically (Steam library can be on any disk).
129///
130/// Returns [`DiscoveryError::LogFileMissing`] (not [`DiscoveryError::UnsupportedPlatform`])
131/// when no candidate contains a `Player.log`, matching the absent-file
132/// contract of the Windows/macOS arms.
133#[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    // Nothing found: report the Lutris path (last candidate) as the missing
156    // path so callers can surface a useful "file not found" message.
157    // The Lutris candidate is always present in the list (linux_candidate_base_dirs
158    // always appends it), so last() is always Some; fall back to building the
159    // Lutris path directly if the vector were somehow empty.
160    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/// Returns the ordered `.../LocalLow` candidate base directories for Linux
181/// MTGA discovery (Steam/Proton first, Lutris second).
182///
183/// This pure helper takes `home` and an optional `steam_lib` so it can be
184/// unit-tested with temp directories without mutating `$HOME`.
185#[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    // Steam/Proton (primary)
190    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    // Lutris (fallback, UNVERIFIED)
205    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/// Returns [`DiscoveryError::UnsupportedPlatform`] on non-Windows/macOS/Linux targets.
219#[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
224// ---------------------------------------------------------------------------
225// Path construction (platform-independent)
226// ---------------------------------------------------------------------------
227
228/// Builds [`LogPaths`] from a platform base directory.
229///
230/// Appends the MTGA-specific subdirectory components and log file names.
231fn 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
242/// Checks whether the primary log file exists on disk.
243///
244/// Returns the paths on success, or [`DiscoveryError::LogFileMissing`]
245/// if `Player.log` is not found.
246fn 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
258// ---------------------------------------------------------------------------
259// Public API
260// ---------------------------------------------------------------------------
261
262/// Resolves the expected platform-specific log file paths without checking
263/// whether the files exist on disk.
264///
265/// Useful for displaying the expected path in configuration UI or logs.
266/// Use [`discover_log_file`] to also verify the file exists.
267///
268/// Note: on Linux the base directory is auto-discovered (Steam library can
269/// be on any configured disk), so the Linux arm performs existence checks
270/// internally as part of resolution.  A successful return on Linux means
271/// the file was found; a [`DiscoveryError::LogFileMissing`] return means
272/// no candidate location contained a `Player.log`.
273///
274/// # Errors
275///
276/// - [`DiscoveryError::UnsupportedPlatform`] on platforms other than
277///   Windows, macOS, and Linux.
278/// - [`DiscoveryError::BaseDirNotFound`] if the platform base directory
279///   cannot be resolved.
280pub fn resolve_log_paths() -> Result<LogPaths, DiscoveryError> {
281    let base_dir = resolve_base_dir()?;
282    Ok(build_log_paths(base_dir))
283}
284
285/// Resolves the platform-specific `Player.log` path and verifies the file
286/// exists on disk.
287///
288/// When this returns [`DiscoveryError::LogFileMissing`], callers should
289/// notify the user and poll periodically (e.g., every 5 seconds) until
290/// the file appears.
291///
292/// # Errors
293///
294/// - [`DiscoveryError::UnsupportedPlatform`] on platforms other than
295///   Windows, macOS, and Linux.
296/// - [`DiscoveryError::BaseDirNotFound`] if the platform base directory
297///   cannot be resolved.
298/// - [`DiscoveryError::LogFileMissing`] if the resolved path does not
299///   exist on disk.
300pub fn discover_log_file() -> Result<LogPaths, DiscoveryError> {
301    let paths = resolve_log_paths()?;
302    check_existence(paths)
303}
304
305// ---------------------------------------------------------------------------
306// Tests
307// ---------------------------------------------------------------------------
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::fs;
313
314    type TestResult = Result<(), Box<dyn std::error::Error>>;
315
316    // -- Path construction (platform-independent) --
317
318    #[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        // On all platforms, PathBuf joins with the OS separator, but the
338        // path components are correct regardless.
339        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    // -- LogPaths accessors --
387
388    #[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    // -- Existence check --
396
397    #[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        // Directory exists but Player.log does not.
428        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        // Directory exists but Player.log does not.
455
456        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    // -- DiscoveryError display --
463
464    #[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    // -- DiscoveryError properties --
490
491    #[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    // -- Platform-specific resolution --
501
502    #[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    // -- Linux candidate base dir discovery (pure helper, tempdir-safe) --
517
518    #[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        /// Build the full Player.log path under a `LocalLow` base dir.
527        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            // Steam candidate is first
542            assert!(candidates[0]
543                .to_string_lossy()
544                .contains("steamapps/compatdata"));
545            assert!(candidates[0].to_string_lossy().contains("2141910"));
546            // Lutris candidate is second
547            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            // No steam lib; only Lutris
596            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            // No Player.log anywhere
624            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    // -- Integration: discover_log_file --
689
690    #[test]
691    fn test_discover_log_file_returns_error_in_ci() {
692        // On unsupported platforms, discover_log_file returns
693        // UnsupportedPlatform. On supported platforms, it returns
694        // LogFileMissing because MTGA is not installed in CI.
695        let result = discover_log_file();
696        assert!(result.is_err());
697    }
698}