use std::sync::LazyLock; use database::platform::Platform; use regex::Regex; use super::placeholder::*; pub fn normalize(path: &str, os: Platform) -> String { let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/"); if path == "~" || path.starts_with("~/") { path = path.replacen('~', HOME, 1); } static CONSECUTIVE_SLASHES: LazyLock = LazyLock::new(|| Regex::new(r"/{2,}").unwrap()); static UNNECESSARY_DOUBLE_STAR_1: LazyLock = LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap()); static UNNECESSARY_DOUBLE_STAR_2: LazyLock = LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap()); static ENDING_WILDCARD: LazyLock = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap()); static ENDING_DOT: LazyLock = LazyLock::new(|| Regex::new(r"(/\.)$").unwrap()); static INTERMEDIATE_DOT: LazyLock = LazyLock::new(|| Regex::new(r"(/\./)").unwrap()); static BLANK_SEGMENT: LazyLock = LazyLock::new(|| Regex::new(r"(/\s+/)").unwrap()); static APP_DATA: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%appdata%").unwrap()); static APP_DATA_ROAMING: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap()); static APP_DATA_LOCAL: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap()); static APP_DATA_LOCAL_2: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap()); static USER_PROFILE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap()); static DOCUMENTS: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap()); for (pattern, replacement) in [ (&CONSECUTIVE_SLASHES, "/"), (&UNNECESSARY_DOUBLE_STAR_1, "${1}*"), (&UNNECESSARY_DOUBLE_STAR_2, "*${1}"), (&ENDING_WILDCARD, ""), (&ENDING_DOT, ""), (&INTERMEDIATE_DOT, "/"), (&BLANK_SEGMENT, "/"), (&APP_DATA, WIN_APP_DATA), (&APP_DATA_ROAMING, WIN_APP_DATA), (&APP_DATA_LOCAL, WIN_LOCAL_APP_DATA), (&APP_DATA_LOCAL_2, &format!("{}/", WIN_LOCAL_APP_DATA)), (&USER_PROFILE, HOME), (&DOCUMENTS, WIN_DOCUMENTS), ] { path = pattern.replace_all(&path, replacement).to_string(); } if os == Platform::Windows { let documents_2: Regex = Regex::new(r"(?i)/Documents").unwrap(); #[allow(clippy::single_element_loop)] for (pattern, replacement) in [(&documents_2, WIN_DOCUMENTS)] { path = pattern.replace_all(&path, replacement).to_string(); } } for (pattern, replacement) in [ ("{64BitSteamID}", STORE_USER_ID), ("{Steam3AccountID}", STORE_USER_ID), ] { path = path.replace(pattern, replacement); } path } fn too_broad(path: &str) -> bool { println!("Path: {}", path); use { BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA, }; let path_lower = path.to_lowercase(); for item in ALL { if path == *item { return true; } } for item in AVOID_WILDCARDS { if path.starts_with(&format!("{}/*", item)) || path.starts_with(&format!("{}/{}", item, STORE_USER_ID)) { return true; } } // These paths are present whether or not the game is installed. // If possible, they should be narrowed down on the wiki. for item in [ format!("{}/{}", BASE, STORE_USER_ID), // because `` is handled as `*` format!("{}/Documents", HOME), format!("{}/Saved Games", HOME), format!("{}/AppData", HOME), format!("{}/AppData/Local", HOME), format!("{}/AppData/Local/Packages", HOME), format!("{}/AppData/LocalLow", HOME), format!("{}/AppData/Roaming", HOME), format!("{}/Documents/My Games", HOME), format!("{}/Library/Application Support", HOME), format!("{}/Library/Application Support/UserData", HOME), format!("{}/Library/Preferences", HOME), format!("{}/.renpy", HOME), format!("{}/.renpy/persistent", HOME), format!("{}/Library", HOME), format!("{}/Library/RenPy", HOME), format!("{}/Telltale Games", HOME), format!("{}/config", ROOT), format!("{}/MMFApplications", WIN_APP_DATA), format!("{}/RenPy", WIN_APP_DATA), format!("{}/RenPy/persistent", WIN_APP_DATA), format!("{}/win.ini", WIN_DIR), format!("{}/SysWOW64", WIN_DIR), format!("{}/My Games", WIN_DOCUMENTS), format!("{}/Telltale Games", WIN_DOCUMENTS), format!("{}/unity3d", XDG_CONFIG), format!("{}/unity3d", XDG_DATA), "C:/Program Files".to_string(), "C:/Program Files (x86)".to_string(), ] { let item = item.to_lowercase(); if path_lower == item || path_lower.starts_with(&format!("{}/*", item)) || path_lower.starts_with(&format!("{}/{}", item, STORE_USER_ID.to_lowercase())) || path_lower.starts_with(&format!("{}/savesdir", item)) { return true; } } // Drive letters: let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap(); if drives.is_match(path) { return true; } // Colon not for a drive letter if path.get(2..).is_some_and(|path| path.contains(':')) { return true; } // Root: if path == "/" { return true; } // Relative path wildcard: if path.starts_with('*') { return true; } false } pub fn usable(path: &str) -> bool { let unprintable: Regex = Regex::new(r"(\p{Cc}|\p{Cf})").unwrap(); !path.is_empty() && !path.contains("{{") && !path.starts_with("./") && !path.starts_with("../") && !too_broad(path) && !unprintable.is_match(path) }