diff --git a/src/setup/common.rs b/src/setup/common.rs index 0a1f7f8..7b67170 100644 --- a/src/setup/common.rs +++ b/src/setup/common.rs @@ -837,4 +837,129 @@ mod tests { assert!(!sa2_mods.is_empty()); assert_ne!(sadx_mods.len(), sa2_mods.len()); } + + #[test] + fn test_find_mod_root_three_levels_deep_returns_none() { + // find_mod_root only searches two levels deep; three levels should return None + let tmp = tempfile::tempdir().unwrap(); + let staging = tmp.path().join("staging"); + let deep = staging.join("a").join("b").join("DeepMod"); + std::fs::create_dir_all(&deep).unwrap(); + std::fs::write(deep.join("mod.ini"), b"[mod]").unwrap(); + + assert!(find_mod_root(&staging).is_none()); + } + + #[test] + fn test_move_dir_contents_empty_source() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("empty_src"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(&src).unwrap(); + + move_dir_contents(&src, &dest).unwrap(); + assert!(dest.is_dir()); + assert!(std::fs::read_dir(&dest).unwrap().next().is_none()); + } + + #[test] + fn test_move_dir_contents_nested_subdirectory() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let subdir = src.join("sub"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(&subdir).unwrap(); + std::fs::write(src.join("top.txt"), b"top").unwrap(); + std::fs::write(subdir.join("nested.txt"), b"nested").unwrap(); + + move_dir_contents(&src, &dest).unwrap(); + + assert!(dest.join("top.txt").is_file()); + assert!(dest.join("sub").join("nested.txt").is_file()); + } + + #[test] + fn test_proton_prefix_standard_path() { + let game_path = std::path::Path::new( + "/home/user/.local/share/Steam/steamapps/common/Sonic Adventure DX", + ); + let prefix = proton_prefix(game_path, 71250); + assert_eq!( + prefix, + std::path::PathBuf::from( + "/home/user/.local/share/Steam/steamapps/compatdata/71250/pfx" + ) + ); + } + + #[test] + fn test_proton_prefix_shallow_path_fallback() { + // A path with no grandparent falls back to game_path/pfx + let game_path = std::path::Path::new("/game"); + let prefix = proton_prefix(game_path, 71250); + assert_eq!(prefix, std::path::PathBuf::from("/game/pfx")); + } + + #[test] + fn test_proton_prefix_sa2_app_id() { + let game_path = std::path::Path::new("/mnt/steam/steamapps/common/Sonic Adventure 2"); + let prefix = proton_prefix(game_path, 213610); + assert_eq!( + prefix, + std::path::PathBuf::from("/mnt/steam/steamapps/compatdata/213610/pfx") + ); + } + + #[test] + fn test_find_file_icase_finds_uppercase_variant() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("CHRMODELS.DLL"), b"").unwrap(); + + let found = find_file_icase(tmp.path(), "chrmodels.dll"); + assert!(found.is_some()); + assert!(found.unwrap().ends_with("CHRMODELS.DLL")); + } + + #[test] + fn test_find_file_icase_missing_returns_none() { + let tmp = tempfile::tempdir().unwrap(); + assert!(find_file_icase(tmp.path(), "nonexistent.dll").is_none()); + } + + #[test] + fn test_find_file_icase_nonexistent_dir_returns_none() { + let path = std::path::Path::new("/nonexistent/path/that/does/not/exist"); + assert!(find_file_icase(path, "anything.dll").is_none()); + } + + #[test] + fn test_mod_entry_dir_name_fallback_to_name() { + // When dir_name is None, the name field is used as the directory name + let mod_entry = ModEntry { + name: "MyMod", + source: ModSource::DirectUrl { url: "https://example.com/mod.7z" }, + description: "A test mod", + full_description: None, + pictures: &[], + dir_name: None, + links: &[], + }; + let dir_name = mod_entry.dir_name.unwrap_or(mod_entry.name); + assert_eq!(dir_name, "MyMod"); + } + + #[test] + fn test_mod_entry_explicit_dir_name() { + let mod_entry = ModEntry { + name: "Display Name", + source: ModSource::DirectUrl { url: "https://example.com/mod.7z" }, + description: "A test mod", + full_description: None, + pictures: &[], + dir_name: Some("FolderName"), + links: &[], + }; + let dir_name = mod_entry.dir_name.unwrap_or(mod_entry.name); + assert_eq!(dir_name, "FolderName"); + } } diff --git a/src/steam/game.rs b/src/steam/game.rs index 3880949..48b256f 100644 --- a/src/steam/game.rs +++ b/src/steam/game.rs @@ -6,7 +6,7 @@ pub struct Game { pub path: PathBuf, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GameKind { SADX, SA2, diff --git a/src/steam/library.rs b/src/steam/library.rs index 6ae913a..3b8c979 100644 --- a/src/steam/library.rs +++ b/src/steam/library.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::steam::game::{Game, GameKind}; @@ -9,10 +10,12 @@ pub struct InaccessibleGame { pub library_path: PathBuf, } -fn steam_root() -> Option { - let home = dirs::home_dir()?; +fn steam_roots() -> Vec { + let Some(home) = dirs::home_dir() else { + return vec![]; + }; - // Check common Steam paths + // Check all common Steam paths, including both native and Flatpak installs let candidates = [ home.join(".steam/debian-installation"), home.join(".steam/steam"), @@ -20,23 +23,38 @@ fn steam_root() -> Option { home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), ]; - candidates.into_iter().find(|p| p.is_dir()) + candidates.into_iter().filter(|p| p.is_dir()).collect() } -fn library_folders_path() -> Option { - let root = steam_root()?; - let path = root.join("steamapps/libraryfolders.vdf"); - if path.is_file() { Some(path) } else { None } +fn library_folders_paths() -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut result = vec![]; + + for root in steam_roots() { + let path = root.join("steamapps/libraryfolders.vdf"); + if path.is_file() { + // Deduplicate by canonical path in case multiple Steam roots share a symlink + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + if seen.insert(canonical) { + result.push(path); + } + } + } + + result } -fn find_game_in_libraries( +fn find_all_games_in_libraries( libraries: &vdf::VdfValue, kind: GameKind, -) -> (Option, Option) { +) -> (Vec, Vec) { let app_id = kind.app_id().to_string(); + let mut paths = vec![]; + let mut inaccessible = vec![]; + let folders = match libraries.get("libraryfolders").and_then(|v| v.as_map()) { Some(f) => f, - None => return (None, None), + None => return (vec![], vec![]), }; for folder in folders.values() { @@ -62,21 +80,17 @@ fn find_game_in_libraries( kind.name(), lib_path.display() ); - return ( - None, - Some(InaccessibleGame { - kind, - library_path: lib_path.to_path_buf(), - }), - ); - } - - if let Some(game_path) = find_game_in_library_path(lib_path, kind) { - return (Some(game_path), None); + inaccessible.push(InaccessibleGame { + kind, + library_path: lib_path.to_path_buf(), + }); + } else if let Some(game_path) = find_game_in_library_path(lib_path, kind) { + paths.push(game_path); } } } - (None, None) + + (paths, inaccessible) } fn find_game_in_library_path(lib_path: &Path, kind: GameKind) -> Option { @@ -115,33 +129,55 @@ fn find_game_in_library_path(lib_path: &Path, kind: GameKind) -> Option } } -fn detect_games_from_parsed_vdf( - root: &vdf::VdfValue, +fn detect_games_from_parsed_vdfs( + roots: &[vdf::VdfValue], extra_libraries: &[PathBuf], ) -> DetectionResult { let mut result = DetectionResult::default(); for kind in [GameKind::SADX, GameKind::SA2] { - let (path, inaccessible) = find_game_in_libraries(root, kind); + let mut seen_canonical: HashSet = HashSet::new(); + let mut kind_inaccessible: Vec = vec![]; - if let Some(p) = path { - result.games.push(Game { kind, path: p }); - } else if let Some(inc) = inaccessible { - result.inaccessible.push(inc); - } else { - tracing::info!("{} not found", kind.name()); - } - } + for root in roots { + let (paths, inaccessible) = find_all_games_in_libraries(root, kind); - for lib_path in extra_libraries { - for kind in [GameKind::SADX, GameKind::SA2] { - if result.games.iter().any(|game| game.kind == kind) { - continue; + for path in paths { + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + if seen_canonical.insert(canonical) { + result.games.push(Game { kind, path }); + } } + kind_inaccessible.extend(inaccessible); + } + + for lib_path in extra_libraries { if let Some(path) = find_game_in_library_path(lib_path, kind) { - result.games.push(Game { kind, path }); - result.inaccessible.retain(|inc| inc.kind != kind); + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + if seen_canonical.insert(canonical) { + result.games.push(Game { kind, path }); + } + } + } + + let kind_found = result.games.iter().any(|g| g.kind == kind); + + if kind_found { + // Drop any inaccessible entries for this kind since we found it + } else if kind_inaccessible.is_empty() { + tracing::info!("{} not found", kind.name()); + } else { + // Deduplicate inaccessible entries by canonical library path + let mut seen_inacc: HashSet = HashSet::new(); + for inc in kind_inaccessible { + let canonical = inc + .library_path + .canonicalize() + .unwrap_or_else(|_| inc.library_path.clone()); + if seen_inacc.insert(canonical) { + result.inaccessible.push(inc); + } } } } @@ -161,6 +197,7 @@ pub fn detect_games_from_vdf(vdf_path: &Path) -> DetectionResult { detect_games_from_vdf_with_extra_libraries(vdf_path, &[]) } +#[cfg(test)] pub fn detect_games_from_vdf_with_extra_libraries( vdf_path: &Path, extra_libraries: &[PathBuf], @@ -181,19 +218,28 @@ pub fn detect_games_from_vdf_with_extra_libraries( } }; - detect_games_from_parsed_vdf(&root, extra_libraries) + detect_games_from_parsed_vdfs(&[root], extra_libraries) } pub fn detect_games_with_extra_libraries(extra_libraries: &[PathBuf]) -> DetectionResult { - let vdf_path = match library_folders_path() { - Some(p) => p, - None => { - tracing::warn!("Could not find libraryfolders.vdf"); - return DetectionResult::default(); + let vdf_paths = library_folders_paths(); + + if vdf_paths.is_empty() { + tracing::warn!("Could not find any libraryfolders.vdf"); + } + + let mut roots = vec![]; + for vdf_path in &vdf_paths { + match std::fs::read_to_string(vdf_path) { + Ok(content) => match vdf::parse(&content) { + Some(root) => roots.push(root), + None => tracing::warn!("Failed to parse VDF at {}", vdf_path.display()), + }, + Err(e) => tracing::warn!("Failed to read {}: {}", vdf_path.display(), e), } - }; + } - detect_games_from_vdf_with_extra_libraries(&vdf_path, extra_libraries) + detect_games_from_parsed_vdfs(&roots, extra_libraries) } #[cfg(test)] @@ -235,9 +281,9 @@ mod tests { std::fs::write(game_dir.join("Sonic Adventure DX.exe"), "").unwrap(); let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["71250"]); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert_eq!(path, Some(game_dir)); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert_eq!(paths, vec![game_dir]); + assert!(inaccessible.is_empty()); } #[test] @@ -251,9 +297,9 @@ mod tests { std::fs::write(game_dir.join("sonic2app.exe"), "").unwrap(); let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["213610"]); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SA2); - assert_eq!(path, Some(game_dir)); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SA2); + assert_eq!(paths, vec![game_dir]); + assert!(inaccessible.is_empty()); } #[test] @@ -268,7 +314,7 @@ mod tests { let inaccessible_path = tmp.path().join("missing-library"); let root = mock_vdf(inaccessible_path.to_str().unwrap(), &["71250"]); - let result = detect_games_from_parsed_vdf(&root, std::slice::from_ref(&extra_lib)); + let result = detect_games_from_parsed_vdfs(&[root], std::slice::from_ref(&extra_lib)); assert!(result.games.iter().any(|game| game.kind == GameKind::SADX)); assert!( @@ -283,29 +329,29 @@ mod tests { fn test_game_not_in_libraries() { let tmp = tempfile::tempdir().unwrap(); let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["400", "500"]); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert!(path.is_none()); - assert!(inaccessible.is_none()); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SA2); - assert!(path.is_none()); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SA2); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); } #[test] fn test_missing_libraryfolders_key() { let root = vdf::VdfValue::Map(HashMap::new()); - let (path, inaccessible) = find_game_in_libraries(&root, GameKind::SADX); - assert!(path.is_none()); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&root, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); } #[test] fn test_find_game_app_present_but_dir_missing() { let tmp = tempfile::tempdir().unwrap(); let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["71250"]); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert!(path.is_none()); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); } #[test] @@ -323,9 +369,9 @@ mod tests { root.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders)); let vdf = vdf::VdfValue::Map(root); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert!(path.is_none()); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); } #[test] @@ -343,9 +389,9 @@ mod tests { root.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders)); let vdf = vdf::VdfValue::Map(root); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert!(path.is_none()); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); } #[test] @@ -356,9 +402,9 @@ mod tests { vdf::VdfValue::String("oops".to_string()), ); let vdf = vdf::VdfValue::Map(root); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert!(path.is_none()); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); } #[test] @@ -378,12 +424,12 @@ mod tests { std::fs::write(sa2_dir.join("sonic2app.exe"), "").unwrap(); let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["71250", "213610"]); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert_eq!(path, Some(sadx_dir)); - assert!(inaccessible.is_none()); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SA2); - assert_eq!(path, Some(sa2_dir)); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert_eq!(paths, vec![sadx_dir]); + assert!(inaccessible.is_empty()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SA2); + assert_eq!(paths, vec![sa2_dir]); + assert!(inaccessible.is_empty()); } #[test] @@ -501,9 +547,9 @@ mod tests { root.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders)); let vdf = vdf::VdfValue::Map(root); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SA2); - assert_eq!(path, Some(game_dir)); - assert!(inaccessible.is_none()); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SA2); + assert_eq!(paths, vec![game_dir]); + assert!(inaccessible.is_empty()); } #[test] @@ -524,10 +570,235 @@ mod tests { root.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders)); let vdf = vdf::VdfValue::Map(root); - let (path, inaccessible) = find_game_in_libraries(&vdf, GameKind::SADX); - assert!(path.is_none()); - let inc = inaccessible.expect("Should detect inaccessible game"); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert_eq!(inaccessible.len(), 1); + let inc = &inaccessible[0]; assert_eq!(inc.kind, GameKind::SADX); assert_eq!(inc.library_path, PathBuf::from("/mnt/games/SteamLibrary")); } + + #[test] + fn test_duplicate_installations_across_steam_roots() { + let tmp = tempfile::tempdir().unwrap(); + let lib1 = tmp.path().join("lib1"); + let lib2 = tmp.path().join("lib2"); + + for lib in [&lib1, &lib2] { + let game_dir = lib + .join("steamapps/common") + .join(GameKind::SADX.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("Sonic Adventure DX.exe"), "").unwrap(); + } + + let root1 = mock_vdf(lib1.to_str().unwrap(), &["71250"]); + let root2 = mock_vdf(lib2.to_str().unwrap(), &["71250"]); + + let result = detect_games_from_parsed_vdfs(&[root1, root2], &[]); + + // Both distinct installations should be reported + let sadx_installs: Vec<_> = result.games.iter().filter(|g| g.kind == GameKind::SADX).collect(); + assert_eq!(sadx_installs.len(), 2, "Expected both SADX installations to be reported"); + assert!(result.inaccessible.is_empty()); + } + + #[test] + fn test_duplicate_installations_same_path_deduped() { + let tmp = tempfile::tempdir().unwrap(); + let lib = tmp.path().join("lib"); + let game_dir = lib + .join("steamapps/common") + .join(GameKind::SADX.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("Sonic Adventure DX.exe"), "").unwrap(); + + // Two Steam roots pointing to the same library + let root1 = mock_vdf(lib.to_str().unwrap(), &["71250"]); + let root2 = mock_vdf(lib.to_str().unwrap(), &["71250"]); + + let result = detect_games_from_parsed_vdfs(&[root1, root2], &[]); + + // Same physical path should only appear once + let sadx_installs: Vec<_> = result.games.iter().filter(|g| g.kind == GameKind::SADX).collect(); + assert_eq!(sadx_installs.len(), 1, "Same path from two Steam roots should be deduplicated"); + } + + #[test] + fn test_inaccessible_deduped_across_roots() { + let mut folder = HashMap::new(); + folder.insert( + "path".to_string(), + vdf::VdfValue::String("/mnt/games/SteamLibrary".to_string()), + ); + let mut apps = HashMap::new(); + apps.insert("71250".to_string(), vdf::VdfValue::String("0".to_string())); + folder.insert("apps".to_string(), vdf::VdfValue::Map(apps.clone())); + + let mut folders = HashMap::new(); + folders.insert("0".to_string(), vdf::VdfValue::Map(folder.clone())); + + let mut root_map = HashMap::new(); + root_map.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders.clone())); + let root1 = vdf::VdfValue::Map(root_map.clone()); + + // Second root with same inaccessible library + let mut root_map2 = HashMap::new(); + root_map2.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders)); + let root2 = vdf::VdfValue::Map(root_map2); + + let result = detect_games_from_parsed_vdfs(&[root1, root2], &[]); + + // Same inaccessible library from two roots should only appear once + assert_eq!( + result.inaccessible.len(), + 1, + "Same inaccessible library from two roots should be deduplicated" + ); + } + + #[test] + fn test_no_vdf_roots_finds_game_via_extra_library() { + let tmp = tempfile::tempdir().unwrap(); + let lib = tmp.path().join("lib"); + let game_dir = lib + .join("steamapps/common") + .join(GameKind::SADX.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("Sonic Adventure DX.exe"), "").unwrap(); + + // No VDF roots at all (e.g. Steam not installed), only an extra library + let result = detect_games_from_parsed_vdfs(&[], std::slice::from_ref(&lib)); + + assert!(result.games.iter().any(|g| g.kind == GameKind::SADX)); + assert!(result.inaccessible.is_empty()); + } + + #[test] + fn test_extra_libraries_duplicate_paths_deduped() { + let tmp = tempfile::tempdir().unwrap(); + let lib = tmp.path().join("lib"); + let game_dir = lib + .join("steamapps/common") + .join(GameKind::SADX.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("Sonic Adventure DX.exe"), "").unwrap(); + + // Same path appears twice in extra_libraries (e.g. user granted access twice) + let result = detect_games_from_parsed_vdfs(&[], &[lib.clone(), lib.clone()]); + + let sadx_installs: Vec<_> = result.games.iter().filter(|g| g.kind == GameKind::SADX).collect(); + assert_eq!(sadx_installs.len(), 1, "Same extra library path should not produce duplicates"); + } + + #[test] + fn test_game_in_vdf_and_extra_library_same_path_deduped() { + let tmp = tempfile::tempdir().unwrap(); + let lib = tmp.path().join("lib"); + let game_dir = lib + .join("steamapps/common") + .join(GameKind::SADX.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("Sonic Adventure DX.exe"), "").unwrap(); + + // Same library appears in both VDF and extra_libraries + let root = mock_vdf(lib.to_str().unwrap(), &["71250"]); + let result = detect_games_from_parsed_vdfs(&[root], std::slice::from_ref(&lib)); + + let sadx_installs: Vec<_> = result.games.iter().filter(|g| g.kind == GameKind::SADX).collect(); + assert_eq!(sadx_installs.len(), 1, "Game in both VDF and extra_library should not be duplicated"); + } + + #[test] + fn test_library_folder_exists_but_game_dir_missing() { + // Library path exists but the game subdirectory does not + let tmp = tempfile::tempdir().unwrap(); + let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["71250"]); + // steamapps/common/Sonic Adventure DX/ is NOT created + + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); + } + + #[test] + fn test_game_dir_exists_but_exe_missing() { + // Game directory exists but contains no recognized executable + let tmp = tempfile::tempdir().unwrap(); + let game_dir = tmp + .path() + .join("steamapps/common") + .join(GameKind::SADX.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("unrelated_file.txt"), "").unwrap(); + + let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["71250"]); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SADX); + assert!(paths.is_empty()); + assert!(inaccessible.is_empty()); + } + + #[test] + fn test_sa2_alt_exe_sonic_exe_detected() { + // SA2 directory with only sonic.exe (the fallback executable) + let tmp = tempfile::tempdir().unwrap(); + let game_dir = tmp + .path() + .join("steamapps/common") + .join(GameKind::SA2.install_dir()); + std::fs::create_dir_all(&game_dir).unwrap(); + std::fs::write(game_dir.join("sonic.exe"), "").unwrap(); + + let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["213610"]); + let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SA2); + assert_eq!(paths, vec![game_dir]); + assert!(inaccessible.is_empty()); + } + + #[test] + fn test_empty_vdf_roots_and_empty_extra_libraries() { + let result = detect_games_from_parsed_vdfs(&[], &[]); + assert!(result.games.is_empty()); + assert!(result.inaccessible.is_empty()); + } + + #[test] + fn test_multiple_games_in_multiple_libraries_single_vdf() { + // SADX in lib1, SA2 in lib2 — both in the same VDF + let tmp = tempfile::tempdir().unwrap(); + let lib1 = tmp.path().join("lib1"); + let lib2 = tmp.path().join("lib2"); + + let sadx_dir = lib1.join("steamapps/common").join(GameKind::SADX.install_dir()); + let sa2_dir = lib2.join("steamapps/common").join(GameKind::SA2.install_dir()); + std::fs::create_dir_all(&sadx_dir).unwrap(); + std::fs::create_dir_all(&sa2_dir).unwrap(); + std::fs::write(sadx_dir.join("Sonic Adventure DX.exe"), "").unwrap(); + std::fs::write(sa2_dir.join("sonic2app.exe"), "").unwrap(); + + let mut apps1 = HashMap::new(); + apps1.insert("71250".to_string(), vdf::VdfValue::String("0".to_string())); + let mut folder1 = HashMap::new(); + folder1.insert("path".to_string(), vdf::VdfValue::String(lib1.to_str().unwrap().to_string())); + folder1.insert("apps".to_string(), vdf::VdfValue::Map(apps1)); + + let mut apps2 = HashMap::new(); + apps2.insert("213610".to_string(), vdf::VdfValue::String("0".to_string())); + let mut folder2 = HashMap::new(); + folder2.insert("path".to_string(), vdf::VdfValue::String(lib2.to_str().unwrap().to_string())); + folder2.insert("apps".to_string(), vdf::VdfValue::Map(apps2)); + + let mut folders = HashMap::new(); + folders.insert("0".to_string(), vdf::VdfValue::Map(folder1)); + folders.insert("1".to_string(), vdf::VdfValue::Map(folder2)); + let mut root_map = HashMap::new(); + root_map.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders)); + let vdf = vdf::VdfValue::Map(root_map); + + let result = detect_games_from_parsed_vdfs(&[vdf], &[]); + assert_eq!(result.games.len(), 2); + assert!(result.games.iter().any(|g| g.kind == GameKind::SADX)); + assert!(result.games.iter().any(|g| g.kind == GameKind::SA2)); + assert!(result.inaccessible.is_empty()); + } } diff --git a/src/steam/vdf.rs b/src/steam/vdf.rs index eae976c..b16bca8 100644 --- a/src/steam/vdf.rs +++ b/src/steam/vdf.rs @@ -58,58 +58,26 @@ impl<'a> Parser<'a> { } self.pos += 1; - let start = self.pos; let mut result = String::new(); + let mut start = self.pos; while self.pos < self.input.len() { let ch = self.input.as_bytes()[self.pos]; if ch == b'\\' && self.pos + 1 < self.input.len() { result.push_str(&self.input[start..self.pos]); self.pos += 1; - let escaped = self.input.as_bytes()[self.pos]; - match escaped { + match self.input.as_bytes()[self.pos] { b'n' => result.push('\n'), b't' => result.push('\t'), b'\\' => result.push('\\'), b'"' => result.push('"'), - _ => { + b => { result.push('\\'); - result.push(escaped as char); + result.push(b as char); } } self.pos += 1; - return self.parse_quoted_string_continue(result); - } else if ch == b'"' { - result.push_str(&self.input[start..self.pos]); - self.pos += 1; - return Some(result); - } else { - self.pos += 1; - } - } - None - } - - fn parse_quoted_string_continue(&mut self, mut result: String) -> Option { - let start = self.pos; - while self.pos < self.input.len() { - let ch = self.input.as_bytes()[self.pos]; - if ch == b'\\' && self.pos + 1 < self.input.len() { - result.push_str(&self.input[start..self.pos]); - self.pos += 1; - let escaped = self.input.as_bytes()[self.pos]; - match escaped { - b'n' => result.push('\n'), - b't' => result.push('\t'), - b'\\' => result.push('\\'), - b'"' => result.push('"'), - _ => { - result.push('\\'); - result.push(escaped as char); - } - } - self.pos += 1; - return self.parse_quoted_string_continue(result); + start = self.pos; } else if ch == b'"' { result.push_str(&self.input[start..self.pos]); self.pos += 1; @@ -537,4 +505,151 @@ mod tests { assert_eq!(map.get("key_0").unwrap().as_str().unwrap(), "0"); assert_eq!(map.get("key_9999").unwrap().as_str().unwrap(), "9999"); } + + #[test] + fn test_parse_unicode_values() { + let input = "\"root\" { \"path\" \"/home/ünïcödé/♪music/Steam\" }"; + let root = parse(input).unwrap(); + assert_eq!( + root.get("root") + .unwrap() + .get("path") + .unwrap() + .as_str() + .unwrap(), + "/home/ünïcödé/♪music/Steam" + ); + } + + #[test] + fn test_parse_unicode_key() { + let input = "\"root\" { \"ключ\" \"значение\" }"; + let root = parse(input).unwrap(); + assert_eq!( + root.get("root") + .unwrap() + .get("ключ") + .unwrap() + .as_str() + .unwrap(), + "значение" + ); + } + + #[test] + fn test_parse_empty_key() { + // VDF allows empty string keys (edge case from some malformed files) + let input = r#""root" { "" "value" }"#; + let root = parse(input).unwrap(); + assert_eq!( + root.get("root") + .unwrap() + .get("") + .unwrap() + .as_str() + .unwrap(), + "value" + ); + } + + #[test] + fn test_parse_backslash_at_eof_in_string() { + // Backslash with no following char: unterminated escape → parse fails + let input = "\"root\" \"value\\"; + assert!(parse(input).is_none()); + } + + #[test] + fn test_parse_newline_in_path_value() { + // Real Steam paths never contain newlines, but the parser should handle + // the \n escape sequence producing an actual newline in the value. + let input = r#""root" "line1\nline2""#; + let root = parse(input).unwrap(); + assert_eq!(root.get("root").unwrap().as_str().unwrap(), "line1\nline2"); + } + + #[test] + fn test_parse_multiple_consecutive_escape_sequences() { + // \t\n\\ in one value + let input = r#""root" "\t\n\\""#; + let root = parse(input).unwrap(); + assert_eq!(root.get("root").unwrap().as_str().unwrap(), "\t\n\\"); + } + + #[test] + fn test_parse_windows_style_path() { + // Some VDF entries on Linux can contain Windows-style paths with backslashes. + // Each \\ in VDF decodes to a single \, which is what a Windows path contains. + let input = r#""root" { "path" "C:\\Program Files\\Steam" }"#; + let root = parse(input).unwrap(); + assert_eq!( + root.get("root") + .unwrap() + .get("path") + .unwrap() + .as_str() + .unwrap(), + "C:\\Program Files\\Steam" + ); + } + + #[test] + fn test_parse_value_is_map_not_string() { + let input = r#""root" { "nested" { "key" "val" } }"#; + let root = parse(input).unwrap(); + // Trying to get "nested" as a string should fail + assert!( + root.get("root") + .unwrap() + .get("nested") + .unwrap() + .as_str() + .is_none() + ); + // Getting it as a map should work + assert!( + root.get("root") + .unwrap() + .get("nested") + .unwrap() + .as_map() + .is_some() + ); + } + + #[test] + fn test_parse_comment_before_closing_brace() { + let input = r#" +"root" +{ + "key" "value" + // This comment is right before the closing brace +} +"#; + let root = parse(input).unwrap(); + assert_eq!( + root.get("root") + .unwrap() + .get("key") + .unwrap() + .as_str() + .unwrap(), + "value" + ); + } + + #[test] + fn test_parse_mixed_crlf_lf_line_endings() { + let input = "\"root\"\r\n{\r\n\t\"key\"\t\"value\"\r\n}\r\n"; + let root = parse(input).unwrap(); + assert_eq!( + root.get("root") + .unwrap() + .get("key") + .unwrap() + .as_str() + .unwrap(), + "value" + ); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 435f906..7f35084 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,4 @@ pub mod game_card; -pub mod progress_page; pub mod setup_page; pub mod welcome_page; diff --git a/src/ui/progress_page.rs b/src/ui/progress_page.rs deleted file mode 100644 index a4552d5..0000000 --- a/src/ui/progress_page.rs +++ /dev/null @@ -1,77 +0,0 @@ -use adw::prelude::*; -use adw::subclass::prelude::*; -use gtk::glib; - -mod imp { - use super::*; - - #[derive(Debug, Default, gtk::CompositeTemplate)] - #[template(resource = "/io/github/astrovm/AdventureMods/resources/ui/progress_page.ui")] - pub struct AdventureModsProgressPage { - #[template_child] - pub progress_title: TemplateChild, - #[template_child] - pub progress_status: TemplateChild, - #[template_child] - pub progress_bar: TemplateChild, - #[template_child] - pub cancel_button: TemplateChild, - } - - #[glib::object_subclass] - impl ObjectSubclass for AdventureModsProgressPage { - const NAME: &'static str = "AdventureModsProgressPage"; - type Type = super::AdventureModsProgressPage; - type ParentType = adw::Bin; - - fn class_init(klass: &mut Self::Class) { - klass.bind_template(); - } - - fn instance_init(obj: &glib::subclass::InitializingObject) { - obj.init_template(); - } - } - - impl ObjectImpl for AdventureModsProgressPage {} - impl WidgetImpl for AdventureModsProgressPage {} - impl BinImpl for AdventureModsProgressPage {} -} - -glib::wrapper! { - pub struct AdventureModsProgressPage(ObjectSubclass) - @extends gtk::Widget, adw::Bin, - @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; -} - -impl AdventureModsProgressPage { - pub fn new() -> Self { - glib::Object::builder().build() - } - - pub fn set_title(&self, title: &str) { - self.imp().progress_title.set_label(title); - } - - pub fn set_status(&self, status: &str) { - self.imp().progress_status.set_label(status); - } - - pub fn set_fraction(&self, fraction: f64) { - self.imp().progress_bar.set_fraction(fraction); - } - - pub fn set_progress_text(&self, text: &str) { - self.imp().progress_bar.set_text(Some(text)); - } - - pub fn pulse(&self) { - self.imp().progress_bar.pulse(); - } - - pub fn connect_cancel(&self, callback: F) { - self.imp().cancel_button.connect_clicked(move |_| { - callback(); - }); - } -} diff --git a/src/ui/setup_page.rs b/src/ui/setup_page.rs index 670d9b9..9b682c2 100644 --- a/src/ui/setup_page.rs +++ b/src/ui/setup_page.rs @@ -998,4 +998,15 @@ mod tests { fn initial_preview_is_none_when_no_mods_exist() { assert_eq!(initial_preview_index(0, &[0]), None); } + + #[test] + fn initial_preview_is_none_when_no_mods_and_no_selection() { + assert_eq!(initial_preview_index(0, &[]), None); + } + + #[test] + fn initial_preview_falls_back_to_zero_when_all_selections_out_of_range() { + // All selected indices are out of range, but mods exist + assert_eq!(initial_preview_index(3, &[5, 10, 99]), Some(0)); + } } diff --git a/src/ui/welcome_page.rs b/src/ui/welcome_page.rs index 825e31e..a38ed4f 100644 --- a/src/ui/welcome_page.rs +++ b/src/ui/welcome_page.rs @@ -3,6 +3,7 @@ use adw::subclass::prelude::*; use gtk::gio; use gtk::glib; +use crate::steam::game::GameKind; use crate::steam::library::DetectionResult; use crate::ui::game_card::AdventureModsGameCard; @@ -150,22 +151,43 @@ impl AdventureModsWelcomePage { return; } - for game in result.games { - let card = AdventureModsGameCard::new(&game); - - let game_clone = game.clone(); - let nav_view_clone = nav_view.clone(); - card.connect_setup_clicked(move || { - let setup_page = - crate::ui::setup_page::AdventureModsSetupPage::new(game_clone.clone()); - let nav_page = adw::NavigationPage::builder() - .title(game_clone.kind.name()) - .child(&setup_page) + for kind in [GameKind::SADX, GameKind::SA2] { + let kind_games: Vec<_> = result.games.iter().filter(|g| g.kind == kind).collect(); + if kind_games.is_empty() { + continue; + } + + if kind_games.len() > 1 { + let label = gtk::Label::builder() + .label(format!( + "Multiple {} installations found. Select one to set up:", + kind.name() + )) + .justify(gtk::Justification::Center) + .wrap(true) + .margin_bottom(4) .build(); - nav_view_clone.push(&nav_page); - }); + label.add_css_class("heading"); + games_box.append(&label); + } - games_box.append(&card); + for game in kind_games { + let card = AdventureModsGameCard::new(game); + + let game_clone = game.clone(); + let nav_view_clone = nav_view.clone(); + card.connect_setup_clicked(move || { + let setup_page = + crate::ui::setup_page::AdventureModsSetupPage::new(game_clone.clone()); + let nav_page = adw::NavigationPage::builder() + .title(game_clone.kind.name()) + .child(&setup_page) + .build(); + nav_view_clone.push(&nav_page); + }); + + games_box.append(&card); + } } }