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//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! use manasight_parser::log::discovery;
10//!
11//! // Resolve and verify the log file exists:
12//! match discovery::discover_log_file() {
13//!     Ok(paths) => println!("Found: {}", paths.player_log().display()),
14//!     Err(e) => eprintln!("Discovery failed: {e}"),
15//! }
16//! ```
17//!
18//! When [`discover_log_file`] returns [`DiscoveryError::LogFileMissing`],
19//! callers should notify the user (e.g., "MTG Arena not found" or "Enable
20//! Detailed Logging") and poll periodically until the file appears.
21
22use std::path::{Path, PathBuf};
23
24// ---------------------------------------------------------------------------
25// LogPaths
26// ---------------------------------------------------------------------------
27
28/// Resolved paths to MTG Arena log files.
29///
30/// Both files reside in the same directory. `player_prev_log` contains the
31/// previous session's log and is used for catch-up parsing on startup.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct LogPaths {
34    /// Path to the active `Player.log`.
35    player_log: PathBuf,
36    /// Path to the previous session's `Player-prev.log`.
37    player_prev_log: PathBuf,
38}
39
40impl LogPaths {
41    /// Returns the path to `Player.log`.
42    pub fn player_log(&self) -> &Path {
43        &self.player_log
44    }
45
46    /// Returns the path to `Player-prev.log`.
47    pub fn player_prev_log(&self) -> &Path {
48        &self.player_prev_log
49    }
50}
51
52// ---------------------------------------------------------------------------
53// DiscoveryError
54// ---------------------------------------------------------------------------
55
56/// Errors that can occur during log file discovery.
57#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
58pub enum DiscoveryError {
59    /// The platform-specific base directory could not be determined.
60    ///
61    /// On Windows this means `KnownFolder::LocalAppDataLow` failed to
62    /// resolve. On macOS this means the `HOME` environment variable is
63    /// not set.
64    #[error("could not resolve platform log directory")]
65    BaseDirNotFound,
66
67    /// The resolved log file path does not exist on disk.
68    ///
69    /// Callers should notify the user (e.g., "MTG Arena not found") and
70    /// poll periodically until the file appears.
71    #[error("log file not found at {path}", path = path.display())]
72    LogFileMissing {
73        /// The expected path that was checked.
74        path: PathBuf,
75    },
76
77    /// The current operating system is not supported.
78    ///
79    /// Only Windows and macOS are supported targets.
80    #[error("unsupported platform for log file discovery")]
81    UnsupportedPlatform,
82}
83
84// ---------------------------------------------------------------------------
85// Constants
86// ---------------------------------------------------------------------------
87
88/// Subdirectory components appended to the platform base directory.
89const MTGA_LOG_DIR: &[&str] = &["Wizards Of The Coast", "MTGA"];
90
91/// Name of the active log file.
92const PLAYER_LOG: &str = "Player.log";
93
94/// Name of the previous session's log file.
95const PLAYER_PREV_LOG: &str = "Player-prev.log";
96
97// ---------------------------------------------------------------------------
98// Platform-specific base directory resolution
99// ---------------------------------------------------------------------------
100
101/// Resolves the platform base directory for MTGA logs on Windows.
102///
103/// Uses `KnownFolder::LocalAppDataLow` via the `known-folders` crate.
104#[cfg(target_os = "windows")]
105fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
106    known_folders::get_known_folder_path(known_folders::KnownFolder::LocalAppDataLow)
107        .ok_or(DiscoveryError::BaseDirNotFound)
108}
109
110/// Resolves the platform base directory for MTGA logs on macOS.
111///
112/// Reads the `HOME` environment variable and appends `Library/Logs`.
113#[cfg(target_os = "macos")]
114fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
115    std::env::var("HOME")
116        .ok()
117        .map(|home| PathBuf::from(home).join("Library").join("Logs"))
118        .ok_or(DiscoveryError::BaseDirNotFound)
119}
120
121/// Returns [`DiscoveryError::UnsupportedPlatform`] on non-Windows/macOS targets.
122#[cfg(not(any(target_os = "windows", target_os = "macos")))]
123fn resolve_base_dir() -> Result<PathBuf, DiscoveryError> {
124    Err(DiscoveryError::UnsupportedPlatform)
125}
126
127// ---------------------------------------------------------------------------
128// Path construction (platform-independent)
129// ---------------------------------------------------------------------------
130
131/// Builds [`LogPaths`] from a platform base directory.
132///
133/// Appends the MTGA-specific subdirectory components and log file names.
134fn build_log_paths(base_dir: PathBuf) -> LogPaths {
135    let mut mtga_dir = base_dir;
136    for component in MTGA_LOG_DIR {
137        mtga_dir.push(component);
138    }
139    LogPaths {
140        player_log: mtga_dir.join(PLAYER_LOG),
141        player_prev_log: mtga_dir.join(PLAYER_PREV_LOG),
142    }
143}
144
145/// Checks whether the primary log file exists on disk.
146///
147/// Returns the paths on success, or [`DiscoveryError::LogFileMissing`]
148/// if `Player.log` is not found.
149fn check_existence(paths: LogPaths) -> Result<LogPaths, DiscoveryError> {
150    if paths.player_log.exists() {
151        ::log::info!("discovered log file: {}", paths.player_log.display());
152        Ok(paths)
153    } else {
154        ::log::warn!("log file not found: {}", paths.player_log.display());
155        Err(DiscoveryError::LogFileMissing {
156            path: paths.player_log,
157        })
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Public API
163// ---------------------------------------------------------------------------
164
165/// Resolves the expected platform-specific log file paths without checking
166/// whether the files exist on disk.
167///
168/// Useful for displaying the expected path in configuration UI or logs.
169/// Use [`discover_log_file`] to also verify the file exists.
170///
171/// # Errors
172///
173/// - [`DiscoveryError::UnsupportedPlatform`] on platforms other than
174///   Windows and macOS.
175/// - [`DiscoveryError::BaseDirNotFound`] if the platform base directory
176///   cannot be resolved.
177pub fn resolve_log_paths() -> Result<LogPaths, DiscoveryError> {
178    let base_dir = resolve_base_dir()?;
179    Ok(build_log_paths(base_dir))
180}
181
182/// Resolves the platform-specific `Player.log` path and verifies the file
183/// exists on disk.
184///
185/// When this returns [`DiscoveryError::LogFileMissing`], callers should
186/// notify the user and poll periodically (e.g., every 5 seconds) until
187/// the file appears.
188///
189/// # Errors
190///
191/// - [`DiscoveryError::UnsupportedPlatform`] on unsupported platforms.
192/// - [`DiscoveryError::BaseDirNotFound`] if the platform base directory
193///   cannot be resolved.
194/// - [`DiscoveryError::LogFileMissing`] if the resolved path does not
195///   exist on disk.
196pub fn discover_log_file() -> Result<LogPaths, DiscoveryError> {
197    let paths = resolve_log_paths()?;
198    check_existence(paths)
199}
200
201// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::fs;
209
210    type TestResult = Result<(), Box<dyn std::error::Error>>;
211
212    // -- Path construction (platform-independent) --
213
214    #[test]
215    fn test_build_log_paths_appends_mtga_components() {
216        let base = PathBuf::from("/some/base");
217        let paths = build_log_paths(base);
218        assert_eq!(
219            paths.player_log(),
220            Path::new("/some/base/Wizards Of The Coast/MTGA/Player.log")
221        );
222        assert_eq!(
223            paths.player_prev_log(),
224            Path::new("/some/base/Wizards Of The Coast/MTGA/Player-prev.log")
225        );
226    }
227
228    #[test]
229    fn test_build_log_paths_windows_style_path() {
230        let base = PathBuf::from(r"C:\Users\User\AppData\LocalLow");
231        let paths = build_log_paths(base);
232
233        // On all platforms, PathBuf joins with the OS separator, but the
234        // path components are correct regardless.
235        let log_str = paths.player_log().to_string_lossy();
236        assert!(log_str.contains("Wizards Of The Coast"));
237        assert!(log_str.contains("MTGA"));
238        assert!(log_str.ends_with("Player.log"));
239    }
240
241    #[test]
242    fn test_build_log_paths_macos_style_path() {
243        let base = PathBuf::from("/Users/player/Library/Logs");
244        let paths = build_log_paths(base);
245        assert_eq!(
246            paths.player_log(),
247            Path::new("/Users/player/Library/Logs/Wizards Of The Coast/MTGA/Player.log")
248        );
249    }
250
251    #[test]
252    fn test_build_log_paths_both_files_share_directory() {
253        let paths = build_log_paths(PathBuf::from("/base"));
254        assert_eq!(
255            paths.player_log().parent(),
256            paths.player_prev_log().parent()
257        );
258    }
259
260    #[test]
261    fn test_build_log_paths_player_prev_log_correct_name() {
262        let paths = build_log_paths(PathBuf::from("/base"));
263        let filename = paths
264            .player_prev_log()
265            .file_name()
266            .map(|f| f.to_string_lossy().into_owned())
267            .unwrap_or_default();
268        assert_eq!(filename, "Player-prev.log");
269    }
270
271    #[test]
272    fn test_build_log_paths_player_log_correct_name() {
273        let paths = build_log_paths(PathBuf::from("/base"));
274        let filename = paths
275            .player_log()
276            .file_name()
277            .map(|f| f.to_string_lossy().into_owned())
278            .unwrap_or_default();
279        assert_eq!(filename, "Player.log");
280    }
281
282    // -- LogPaths accessors --
283
284    #[test]
285    fn test_log_paths_clone_is_equal() {
286        let paths = build_log_paths(PathBuf::from("/base"));
287        let cloned = paths.clone();
288        assert_eq!(paths, cloned);
289    }
290
291    // -- Existence check --
292
293    #[test]
294    fn test_check_existence_found_returns_ok() -> TestResult {
295        let dir = tempfile::tempdir()?;
296        let mtga_dir = dir.path().join("Wizards Of The Coast").join("MTGA");
297        fs::create_dir_all(&mtga_dir)?;
298        fs::write(mtga_dir.join("Player.log"), "test log data")?;
299
300        let paths = build_log_paths(dir.path().to_path_buf());
301        let result = check_existence(paths);
302        assert!(result.is_ok());
303        Ok(())
304    }
305
306    #[test]
307    fn test_check_existence_found_returns_correct_paths() -> TestResult {
308        let dir = tempfile::tempdir()?;
309        let mtga_dir = dir.path().join("Wizards Of The Coast").join("MTGA");
310        fs::create_dir_all(&mtga_dir)?;
311        fs::write(mtga_dir.join("Player.log"), "data")?;
312
313        let paths = build_log_paths(dir.path().to_path_buf());
314        let found = check_existence(paths)?;
315        assert_eq!(found.player_log(), mtga_dir.join("Player.log"));
316        assert_eq!(found.player_prev_log(), mtga_dir.join("Player-prev.log"));
317        Ok(())
318    }
319
320    #[test]
321    fn test_check_existence_missing_returns_log_file_missing() -> TestResult {
322        let dir = tempfile::tempdir()?;
323        // Directory exists but Player.log does not.
324        let paths = build_log_paths(dir.path().to_path_buf());
325        let result = check_existence(paths);
326        assert!(matches!(result, Err(DiscoveryError::LogFileMissing { .. })));
327        Ok(())
328    }
329
330    #[test]
331    fn test_check_existence_missing_error_contains_expected_path() -> TestResult {
332        let dir = tempfile::tempdir()?;
333        let paths = build_log_paths(dir.path().to_path_buf());
334        let expected_path = paths.player_log().to_path_buf();
335
336        match check_existence(paths) {
337            Err(DiscoveryError::LogFileMissing { path }) => {
338                assert_eq!(path, expected_path);
339            }
340            other => return Err(format!("expected LogFileMissing, got: {other:?}").into()),
341        }
342        Ok(())
343    }
344
345    #[test]
346    fn test_check_existence_directory_exists_but_no_file() -> TestResult {
347        let dir = tempfile::tempdir()?;
348        let mtga_dir = dir.path().join("Wizards Of The Coast").join("MTGA");
349        fs::create_dir_all(&mtga_dir)?;
350        // Directory exists but Player.log does not.
351
352        let paths = build_log_paths(dir.path().to_path_buf());
353        let result = check_existence(paths);
354        assert!(matches!(result, Err(DiscoveryError::LogFileMissing { .. })));
355        Ok(())
356    }
357
358    // -- DiscoveryError display --
359
360    #[test]
361    fn test_discovery_error_base_dir_not_found_display() {
362        let err = DiscoveryError::BaseDirNotFound;
363        assert_eq!(err.to_string(), "could not resolve platform log directory");
364    }
365
366    #[test]
367    fn test_discovery_error_unsupported_platform_display() {
368        let err = DiscoveryError::UnsupportedPlatform;
369        assert_eq!(
370            err.to_string(),
371            "unsupported platform for log file discovery"
372        );
373    }
374
375    #[test]
376    fn test_discovery_error_log_file_missing_display() {
377        let err = DiscoveryError::LogFileMissing {
378            path: PathBuf::from("/some/path/Player.log"),
379        };
380        let display = err.to_string();
381        assert!(display.contains("/some/path/Player.log"));
382        assert!(display.contains("log file not found"));
383    }
384
385    // -- DiscoveryError properties --
386
387    #[test]
388    fn test_discovery_error_clone_is_equal() {
389        let err = DiscoveryError::LogFileMissing {
390            path: PathBuf::from("/test"),
391        };
392        let cloned = err.clone();
393        assert_eq!(err, cloned);
394    }
395
396    // -- Platform-specific resolution --
397
398    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
399    #[test]
400    fn test_resolve_log_paths_unsupported_platform() {
401        let result = resolve_log_paths();
402        assert!(matches!(result, Err(DiscoveryError::UnsupportedPlatform)));
403    }
404
405    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
406    #[test]
407    fn test_discover_log_file_unsupported_platform() {
408        let result = discover_log_file();
409        assert!(matches!(result, Err(DiscoveryError::UnsupportedPlatform)));
410    }
411
412    #[cfg(target_os = "windows")]
413    #[test]
414    fn test_resolve_log_paths_windows_contains_locallow() -> TestResult {
415        let paths = resolve_log_paths()?;
416        let log_str = paths.player_log().to_string_lossy();
417        assert!(
418            log_str.contains("LocalLow"),
419            "Windows path should contain LocalLow: {log_str}"
420        );
421        Ok(())
422    }
423
424    #[cfg(target_os = "macos")]
425    #[test]
426    fn test_resolve_log_paths_macos_contains_library_logs() -> TestResult {
427        let paths = resolve_log_paths()?;
428        let log_str = paths.player_log().to_string_lossy();
429        assert!(
430            log_str.contains("Library/Logs"),
431            "macOS path should contain Library/Logs: {log_str}"
432        );
433        Ok(())
434    }
435
436    // -- Integration: discover_log_file --
437
438    #[test]
439    fn test_discover_log_file_returns_error_in_ci() {
440        // On unsupported platforms, discover_log_file returns
441        // UnsupportedPlatform. On supported platforms, it returns
442        // LogFileMissing because MTGA is not installed in CI.
443        let result = discover_log_file();
444        assert!(result.is_err());
445    }
446}