diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index a04f7fd..839b35b 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4346,6 +4346,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-upload", "uuid", + "walkdir", "zip 0.6.6", ] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 0c86779..46c31fc 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ zip = "0.6" tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } sha2 = "0.10" rayon = "1.8" +walkdir = "2" uuid = { version = "1.6", features = ["v4"] } tauri-plugin-shell = "2" tauri-plugin-log = "2" diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index cfa56ea..aabeb72 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,12 +1,15 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::fs::{copy as fs_copy, create_dir_all, metadata, read_dir, remove_dir_all, File}; use std::io::{copy, Read, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Instant; use uuid::Uuid; +use walkdir::WalkDir; use zip::ZipArchive; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ @@ -408,6 +411,246 @@ fn get_or_create_machine_id(app_handle: tauri::AppHandle) -> Result, + total_size: u64, + combined_hash: String, +} + +#[derive(Serialize, Clone)] +struct ScanProgress { + current: usize, + total: usize, + current_group: String, +} + +#[derive(Serialize)] +struct ScanResult { + groups: Vec, + total_files: usize, + total_groups: usize, + errors: Vec, +} + +#[tauri::command] +fn scan_mods_folder(app_handle: tauri::AppHandle, mods_path: String) -> Result { + use tauri::Emitter; + + let root = Path::new(&mods_path); + if !root.exists() { + return Err(format!("Mods folder does not exist: {}", mods_path)); + } + + let mod_extensions = ["package", "ts4script"]; + + // Phase 1: Walk directory and group files by parent folder + // Files directly in Mods/ → each is its own group + // Files in Mods/SubFolder/ → grouped together + let mut folder_groups: HashMap> = HashMap::new(); + let mut root_files: Vec = Vec::new(); + + for entry in WalkDir::new(root).min_depth(1).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !mod_extensions.iter().any(|&me| me.eq_ignore_ascii_case(ext)) { + continue; + } + + // Determine if this file is directly in root or in a subfolder + let parent = path.parent().unwrap_or(root); + if parent == root { + root_files.push(path.to_path_buf()); + } else { + // Use the first-level subfolder as group key + let relative = path.strip_prefix(root).unwrap(); + let top_folder = relative.iter().next().unwrap().to_str().unwrap_or("unknown"); + folder_groups + .entry(top_folder.to_string()) + .or_default() + .push(path.to_path_buf()); + } + } + + let total_groups = folder_groups.len() + root_files.len(); + let processed = AtomicUsize::new(0); + let errors = Mutex::new(Vec::::new()); + + // Phase 2: Process folder groups in parallel + let folder_results: Vec = folder_groups + .into_par_iter() + .filter_map(|(folder_name, files)| { + let current = processed.fetch_add(1, Ordering::Relaxed) + 1; + let _ = app_handle.emit("scan-progress", ScanProgress { + current, + total: total_groups, + current_group: folder_name.clone(), + }); + + let mut mod_files = Vec::new(); + let mut total_size: u64 = 0; + let mut hasher = Sha256::new(); + // Sort files for deterministic hash + let mut sorted_files = files.clone(); + sorted_files.sort(); + + for file_path in &sorted_files { + match metadata(file_path) { + Ok(meta) => { + let size = meta.len(); + total_size += size; + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + mod_files.push(ScannedModFile { + path: file_path.to_str().unwrap_or("").to_string(), + file_name, + size, + }); + } + Err(e) => { + errors.lock().unwrap().push(format!("{}: {}", file_path.display(), e)); + continue; + } + } + + // Hash file content + match File::open(file_path) { + Ok(mut f) => { + let mut buffer = [0u8; 64 * 1024]; + loop { + match f.read(&mut buffer) { + Ok(0) => break, + Ok(n) => hasher.update(&buffer[..n]), + Err(e) => { + errors.lock().unwrap().push(format!("{}: {}", file_path.display(), e)); + break; + } + } + } + } + Err(e) => { + errors.lock().unwrap().push(format!("{}: {}", file_path.display(), e)); + } + } + } + + if mod_files.is_empty() { + return None; + } + + let combined_hash = format!("{:x}", hasher.finalize()); + Some(ScannedModGroup { + group_name: folder_name, + is_folder: true, + files: mod_files, + total_size, + combined_hash, + }) + }) + .collect(); + + // Phase 3: Process root-level files in parallel + let root_results: Vec = root_files + .par_iter() + .filter_map(|file_path| { + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let current = processed.fetch_add(1, Ordering::Relaxed) + 1; + let _ = app_handle.emit("scan-progress", ScanProgress { + current, + total: total_groups, + current_group: file_name.clone(), + }); + + let meta = match metadata(file_path) { + Ok(m) => m, + Err(e) => { + errors.lock().unwrap().push(format!("{}: {}", file_path.display(), e)); + return None; + } + }; + + let mut hasher = Sha256::new(); + match File::open(file_path) { + Ok(mut f) => { + let mut buffer = [0u8; 64 * 1024]; + loop { + match f.read(&mut buffer) { + Ok(0) => break, + Ok(n) => hasher.update(&buffer[..n]), + Err(e) => { + errors.lock().unwrap().push(format!("{}: {}", file_path.display(), e)); + return None; + } + } + } + } + Err(e) => { + errors.lock().unwrap().push(format!("{}: {}", file_path.display(), e)); + return None; + } + } + + let mod_name = file_name + .rsplit_once('.') + .map(|(name, _)| name.to_string()) + .unwrap_or(file_name.clone()); + + Some(ScannedModGroup { + group_name: mod_name, + is_folder: false, + files: vec![ScannedModFile { + path: file_path.to_str().unwrap_or("").to_string(), + file_name, + size: meta.len(), + }], + total_size: meta.len(), + combined_hash: format!("{:x}", hasher.finalize()), + }) + }) + .collect(); + + let mut all_groups = folder_results; + all_groups.extend(root_results); + let total_files: usize = all_groups.iter().map(|g| g.files.len()).sum(); + let total_groups_count = all_groups.len(); + let errs = errors.into_inner().unwrap(); + + Ok(ScanResult { + groups: all_groups, + total_files, + total_groups: total_groups_count, + errors: errs, + }) +} + +#[tauri::command] +fn copy_file_to(source: String, target: String) -> Result<(), String> { + if let Some(parent) = Path::new(&target).parent() { + create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?; + } + fs_copy(&source, &target) + .map_err(|e| format!("Failed to copy {} to {}: {}", source, target, e))?; + Ok(()) +} + /// Helper function to recursively copy directories using parallel processing fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { let entries: Vec<_> = read_dir(src)?.collect::, std::io::Error>>()?; @@ -490,8 +733,129 @@ pub fn run() { copy_directory, analyze_zip_content, get_or_create_machine_id, - benchmark_disk_speed + benchmark_disk_speed, + scan_mods_folder, + copy_file_to ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn create_test_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("simsforge_test_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn cleanup(dir: &Path) { + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn test_copy_file_to_copies_file() { + let dir = create_test_dir(); + let source = dir.join("source.package"); + let target = dir.join("subdir").join("target.package"); + + fs::write(&source, b"test content").unwrap(); + + let result = copy_file_to( + source.to_str().unwrap().to_string(), + target.to_str().unwrap().to_string(), + ); + + assert!(result.is_ok()); + assert!(target.exists()); + assert_eq!(fs::read(&target).unwrap(), b"test content"); + + cleanup(&dir); + } + + #[test] + fn test_copy_file_to_creates_parent_dirs() { + let dir = create_test_dir(); + let source = dir.join("source.ts4script"); + let target = dir.join("a").join("b").join("c").join("target.ts4script"); + + fs::write(&source, b"script data").unwrap(); + + let result = copy_file_to( + source.to_str().unwrap().to_string(), + target.to_str().unwrap().to_string(), + ); + + assert!(result.is_ok()); + assert!(target.exists()); + + cleanup(&dir); + } + + #[test] + fn test_copy_file_to_error_on_missing_source() { + let result = copy_file_to( + "/nonexistent/source.package".to_string(), + "/tmp/target.package".to_string(), + ); + + assert!(result.is_err()); + } + + #[test] + fn test_calculate_file_hash_deterministic() { + let dir = create_test_dir(); + let file = dir.join("test.package"); + fs::write(&file, b"deterministic content").unwrap(); + + let hash1 = calculate_file_hash(file.to_str().unwrap().to_string()).unwrap(); + let hash2 = calculate_file_hash(file.to_str().unwrap().to_string()).unwrap(); + + assert_eq!(hash1, hash2); + assert!(!hash1.is_empty()); + assert_eq!(hash1.len(), 64); // SHA-256 hex + + cleanup(&dir); + } + + #[test] + fn test_calculate_file_hash_different_content() { + let dir = create_test_dir(); + let file1 = dir.join("a.package"); + let file2 = dir.join("b.package"); + fs::write(&file1, b"content A").unwrap(); + fs::write(&file2, b"content B").unwrap(); + + let hash1 = calculate_file_hash(file1.to_str().unwrap().to_string()).unwrap(); + let hash2 = calculate_file_hash(file2.to_str().unwrap().to_string()).unwrap(); + + assert_ne!(hash1, hash2); + + cleanup(&dir); + } + + #[test] + fn test_copy_dir_recursive_copies_all_files() { + let dir = create_test_dir(); + let src = dir.join("src"); + let dst = dir.join("dst"); + + fs::create_dir_all(src.join("sub")).unwrap(); + fs::write(src.join("a.package"), b"file a").unwrap(); + fs::write(src.join("sub").join("b.ts4script"), b"file b").unwrap(); + fs::create_dir_all(&dst).unwrap(); + + let result = copy_dir_recursive(&src, &dst); + assert!(result.is_ok()); + assert_eq!(fs::read(dst.join("a.package")).unwrap(), b"file a"); + assert_eq!( + fs::read(dst.join("sub").join("b.ts4script")).unwrap(), + b"file b" + ); + + cleanup(&dir); + } +} diff --git a/app/src/app/profiles/page.tsx b/app/src/app/profiles/page.tsx index 3f52cc5..1a8c418 100644 --- a/app/src/app/profiles/page.tsx +++ b/app/src/app/profiles/page.tsx @@ -5,16 +5,19 @@ import { useProfiles } from '@/context/ProfileContext'; import { useToast } from '@/context/ToastContext'; import CreateProfileModal from '@/components/profile/CreateProfileModal'; import ConfirmationModal from '@/components/ui/ConfirmationModal'; -import { Trash, PencilSimple, CheckCircle, Plus } from '@phosphor-icons/react'; +import { ScanExistingModsModal } from '@/components/profile/ScanExistingModsModal'; +import { Trash, PencilSimple, CheckCircle, Plus, FolderOpen } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import Layout from '@/components/layouts/Layout'; export default function ProfilesPage() { const { t } = useTranslation(); - const { profiles, activeProfile, activateProfile, deleteProfile, updateProfile, isLoading, isInitialized } = + const { profiles, activeProfile, activateProfile, deleteProfile, updateProfile, refreshProfiles, isLoading, isInitialized, getModsPath } = useProfiles(); const { showToast } = useToast(); const [showCreateModal, setShowCreateModal] = useState(false); + const [showScanModal, setShowScanModal] = useState(false); + const modsPath = getModsPath(); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [editDescription, setEditDescription] = useState(''); @@ -115,28 +118,54 @@ export default function ProfilesPage() { {t('profiles.title')} - +
+ {modsPath && ( + + )} + +
{/* Content */} @@ -407,6 +436,16 @@ export default function ProfilesPage() { /> )} + {/* Scan Existing Mods Modal */} + {modsPath && ( + setShowScanModal(false)} + onComplete={() => refreshProfiles()} + /> + )} + {/* Delete Confirmation Modal */} void; + onComplete: () => void; +} + +type ModalState = 'confirm' | 'scanning' | 'complete'; + +export const ScanExistingModsModal: React.FC = ({ + isOpen, + modsPath, + onClose, + onComplete, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState('confirm'); + const [progress, setProgress] = useState({ + phase: 'scanning', + current: 0, + total: 0, + currentName: '', + imported: 0, + skipped: 0, + errors: 0, + }); + const [result, setResult] = useState(null); + + const handleStart = useCallback(async () => { + setState('scanning'); + + try { + const importResult = await existingModScanService.scanAndImport( + modsPath, + (p) => setProgress(p) + ); + setResult(importResult); + setState('complete'); + } catch (error) { + setResult({ + profileId: '', + profileName: '', + imported: 0, + skipped: 0, + errors: [ + { + name: 'scan', + error: error instanceof Error ? error.message : String(error), + }, + ], + totalFiles: 0, + }); + setState('complete'); + } + }, [modsPath]); + + const handleClose = useCallback(() => { + if (state === 'scanning') return; + if (state === 'complete' && result && result.imported > 0) { + onComplete(); + } + setState('confirm'); + setResult(null); + onClose(); + }, [state, result, onComplete, onClose]); + + if (!isOpen) return null; + + const progressPercent = + progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + + return ( +
{ + if (state !== 'scanning' && e.target === e.currentTarget) { + handleClose(); + } + }} + > +
e.stopPropagation()} + > + {/* Header */} +
+

+ {state === 'complete' + ? t('scan.complete_title', 'Import Complete') + : t('scan.title', 'Scan Existing Mods')} +

+ {state !== 'scanning' && ( + + )} +
+ + {/* Content */} +
+ {state === 'confirm' && ( +
+
+ +
+

+ {t( + 'scan.confirm_message', + 'SimsForge will scan your Sims 4 Mods folder for existing mods and import them into a new profile.' + )} +

+

+ {t( + 'scan.confirm_detail', + 'Mods in subfolders will be grouped together. Loose files will be imported individually. Your original files will not be modified.' + )} +

+
+
+ +
+ {modsPath} +
+
+ )} + + {state === 'scanning' && ( +
+
+ {progress.phase === 'scanning' ? ( + + ) : ( + + )} +
+

+ {progress.phase === 'scanning' + ? t('scan.scanning', 'Scanning mods...') + : t('scan.importing', { + current: progress.current, + total: progress.total, + defaultValue: `Importing {{current}} / {{total}}...`, + })} +

+

+ {progress.currentName} +

+
+
+ + {progress.phase === 'importing' && ( +
+
+
+
+
+ + {progress.imported} {t('scan.imported', 'imported')} + + {progress.skipped > 0 && ( + + {progress.skipped}{' '} + {t('scan.already_cached', 'already cached')} + + )} +
+
+ )} +
+ )} + + {state === 'complete' && result && ( +
+
+ {result.imported > 0 ? ( + + ) : ( + + )} +
+

+ {result.imported > 0 + ? t('scan.success_message', { + imported: result.imported, + files: result.totalFiles, + profile: result.profileName, + defaultValue: `{{imported}} mods ({{files}} files) imported into "{{profile}}"`, + }) + : t( + 'scan.no_mods_found', + 'No mods found in the Mods folder.' + )} +

+ {result.skipped > 0 && ( +

+ {t('scan.skipped_message', { + count: result.skipped, + defaultValue: '{{count}} already in cache (skipped)', + })} +

+ )} +
+
+ + {result.errors.length > 0 && ( +
+

+ {t('scan.errors_title', { + count: result.errors.length, + defaultValue: 'Errors ({{count}}):', + })} +

+
    + {result.errors.map((err, index) => ( +
  • + {err.name}: {err.error} +
  • + ))} +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ {state === 'confirm' && ( +
+ + +
+ )} + + {state === 'complete' && ( + + )} +
+
+
+ ); +}; diff --git a/app/src/lib/services/ExistingModScanService.ts b/app/src/lib/services/ExistingModScanService.ts new file mode 100644 index 0000000..deec1a5 --- /dev/null +++ b/app/src/lib/services/ExistingModScanService.ts @@ -0,0 +1,236 @@ +/** + * Existing Mod Scan Service + * + * Scans the Sims 4 Mods folder for existing mods, groups them by folder, + * imports them into SimsForge's cache, and creates a profile. + */ + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { v4 as uuidv4 } from 'uuid'; +import { modCacheService } from './ModCacheService'; +import { profileService } from './ProfileService'; +import type { ProfileMod } from '@/types/profile'; +import { concurrentMap } from '@/lib/utils/concurrencyPool'; +import { diskPerformanceService } from './DiskPerformanceService'; + +interface ScannedModFile { + path: string; + file_name: string; + size: number; +} + +interface ScannedModGroup { + group_name: string; + is_folder: boolean; + files: ScannedModFile[]; + total_size: number; + combined_hash: string; +} + +interface ScanResult { + groups: ScannedModGroup[]; + total_files: number; + total_groups: number; + errors: string[]; +} + +export interface ScanProgress { + phase: 'scanning' | 'importing' | 'complete'; + current: number; + total: number; + currentName: string; + imported: number; + skipped: number; + errors: number; +} + +export interface ScanImportResult { + profileId: string; + profileName: string; + imported: number; + skipped: number; + errors: Array<{ name: string; error: string }>; + totalFiles: number; +} + +export class ExistingModScanService { + async scanAndImport( + modsPath: string, + onProgress: (progress: ScanProgress) => void + ): Promise { + // Phase 1: Scan via Rust + let unlisten: UnlistenFn | null = null; + + try { + unlisten = await listen<{ current: number; total: number; current_group: string }>( + 'scan-progress', + (event) => { + onProgress({ + phase: 'scanning', + current: event.payload.current, + total: event.payload.total, + currentName: event.payload.current_group, + imported: 0, + skipped: 0, + errors: 0, + }); + } + ); + + let scanResult: ScanResult; + try { + scanResult = await invoke('scan_mods_folder', { + modsPath, + }); + } catch (error) { + return { + profileId: '', + profileName: '', + imported: 0, + skipped: 0, + errors: [{ name: 'scan', error: error instanceof Error ? error.message : String(error) }], + totalFiles: 0, + }; + } + + unlisten(); + unlisten = null; + + if (scanResult.groups.length === 0) { + return { + profileId: '', + profileName: '', + imported: 0, + skipped: 0, + errors: scanResult.errors.map((e) => ({ name: 'scan', error: e })), + totalFiles: 0, + }; + } + + // Phase 2: Create profile + const profileName = await this.getUniqueProfileName(); + const profile = await profileService.createProfile( + profileName, + 'Auto-imported from existing Sims 4 Mods folder', + ['imported'] + ); + + // Phase 3: Import groups into cache + const poolSize = await diskPerformanceService.getPoolSize(); + let imported = 0; + let skipped = 0; + const importErrors: Array<{ name: string; error: string }> = []; + + const results = await concurrentMap( + scanResult.groups, + async (group, index) => { + onProgress({ + phase: 'importing', + current: index + 1, + total: scanResult.groups.length, + currentName: group.group_name, + imported, + skipped, + errors: importErrors.length, + }); + + const localModId = uuidv4(); + const filePaths = group.files.map((f) => f.path); + + const cachedMod = await modCacheService.addGroupToCache( + localModId, + group.group_name, + filePaths, + group.combined_hash, + group.total_size, + profile.id + ); + + const isNew = cachedMod.usedByProfiles.length === 1; + + const profileMod: ProfileMod = { + localModId, + isLocal: true, + modName: group.group_name, + fileHash: cachedMod.fileHash, + fileName: group.is_folder + ? group.group_name + : group.files[0]?.file_name || group.group_name, + installDate: new Date().toISOString(), + enabled: true, + cacheLocation: cachedMod.fileHash, + }; + + await profileService.addModToProfile(profile.id, profileMod); + + return isNew; + }, + poolSize, + (completed) => { + onProgress({ + phase: 'importing', + current: completed, + total: scanResult.groups.length, + currentName: '', + imported, + skipped, + errors: importErrors.length, + }); + } + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + if (result.value) { + imported++; + } else { + skipped++; + } + } else { + importErrors.push({ + name: 'unknown', + error: String(result.reason), + }); + } + } + + onProgress({ + phase: 'complete', + current: scanResult.groups.length, + total: scanResult.groups.length, + currentName: '', + imported, + skipped, + errors: importErrors.length, + }); + + return { + profileId: profile.id, + profileName, + imported, + skipped, + errors: importErrors, + totalFiles: scanResult.total_files, + }; + } finally { + unlisten?.(); + } + } + + private async getUniqueProfileName(): Promise { + const baseName = 'My Old Mods'; + const profiles = await profileService.getAllProfiles(); + const names = new Set(profiles.map((p) => p.name)); + + if (!names.has(baseName)) return baseName; + + let counter = 2; + while (names.has(`${baseName} (${counter})`)) { + counter++; + } + return `${baseName} (${counter})`; + } +} + +export const existingModScanService = new ExistingModScanService(); diff --git a/app/src/lib/services/ModCacheService.ts b/app/src/lib/services/ModCacheService.ts index 21fb126..33878fd 100644 --- a/app/src/lib/services/ModCacheService.ts +++ b/app/src/lib/services/ModCacheService.ts @@ -127,6 +127,71 @@ export class ModCacheService { return cachedMod; } + /** + * Add a group of mod files to cache using a pre-computed hash. + * Used by the existing mods scanner where files are already on disk. + */ + async addGroupToCache( + modId: string, + groupName: string, + filePaths: string[], + combinedHash: string, + totalSize: number, + profileId: string + ): Promise { + await this.ensureInitialized(); + + const index = await this.getIndex(); + let cachedMod = index.entries[combinedHash]; + + if (cachedMod) { + if (!cachedMod.usedByProfiles.includes(profileId)) { + cachedMod.usedByProfiles.push(profileId); + index.entries[combinedHash] = cachedMod; + await this.saveIndex(index); + } + return cachedMod; + } + + const cacheEntryDir = await join(this.cacheDir!, combinedHash); + const cacheFilesDir = await join(cacheEntryDir, 'files'); + await mkdir(cacheFilesDir, { recursive: true }); + + const cachedFiles: CachedModFile[] = []; + + for (const filePath of filePaths) { + const fileName = await basename(filePath); + const targetPath = await join(cacheFilesDir, fileName); + await invoke('copy_file_to', { source: filePath, target: targetPath }); + cachedFiles.push({ + relativePath: fileName, + fileName, + fileSize: 0, + }); + } + + cachedMod = { + fileHash: combinedHash, + modId, + fileName: groupName, + fileSize: totalSize, + downloadedAt: new Date().toISOString(), + usedByProfiles: [profileId], + files: cachedFiles, + }; + + const metadataPath = await join(cacheEntryDir, 'metadata.json'); + await writeFile( + metadataPath, + new TextEncoder().encode(JSON.stringify(cachedMod, null, 2)) + ); + + index.entries[combinedHash] = cachedMod; + await this.saveIndex(index); + + return cachedMod; + } + /** * Get cached mod by hash */ @@ -311,22 +376,32 @@ export class ModCacheService { sourcePath: string, destDir: string ): Promise { - // Extract ZIP to cache directory - try { - await invoke('extract_zip', { - zipPath: sourcePath, - destDir, + const lowerPath = sourcePath.toLowerCase(); + const isZip = lowerPath.endsWith('.zip'); + + if (isZip) { + try { + await invoke('extract_zip', { + zipPath: sourcePath, + destDir, + }); + } catch (error) { + console.error('Failed to extract zip:', error); + throw new Error(`Failed to extract mod: ${error}`); + } + } else { + const fileName = await basename(sourcePath); + const targetPath = await join(destDir, fileName); + await invoke('copy_file_to', { + source: sourcePath, + target: targetPath, }); - } catch (error) { - console.error('Failed to extract zip:', error); - throw new Error(`Failed to extract mod: ${error}`); } - // Find all .package files - const packageFiles = await this.findPackageFiles(destDir); + // Find all mod files (.package and .ts4script) + const modFiles = await this.findModFiles(destDir); - return packageFiles.map((filePath) => { - // Calculate relative path from destDir + return modFiles.map((filePath) => { const relativePath = filePath .substring(destDir.length) .replace(/^[\\\/]/, ''); @@ -335,12 +410,12 @@ export class ModCacheService { return { relativePath, fileName, - fileSize: 0, // Size will be calculated if needed + fileSize: 0, }; }); } - private async findPackageFiles(dir: string): Promise { + private async findModFiles(dir: string): Promise { const results: string[] = []; try { @@ -350,10 +425,13 @@ export class ModCacheService { const fullPath = await join(dir, entry.name); if (entry.isDirectory) { - const subResults = await this.findPackageFiles(fullPath); + const subResults = await this.findModFiles(fullPath); results.push(...subResults); - } else if (entry.name.endsWith('.package')) { - results.push(fullPath); + } else { + const lower = entry.name.toLowerCase(); + if (lower.endsWith('.package') || lower.endsWith('.ts4script')) { + results.push(fullPath); + } } } } catch (error) { diff --git a/app/tests/unit/ExistingModScanService.test.ts b/app/tests/unit/ExistingModScanService.test.ts new file mode 100644 index 0000000..74cfb91 --- /dev/null +++ b/app/tests/unit/ExistingModScanService.test.ts @@ -0,0 +1,425 @@ +/** + * Unit tests for ExistingModScanService + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ExistingModScanService } from '@/lib/services/ExistingModScanService'; + +// Mock Tauri APIs +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(() => Promise.resolve(() => {})), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + join: vi.fn((...args: string[]) => args.join('/')), + basename: vi.fn((path: string) => path.split('/').pop() || ''), + appDataDir: vi.fn(() => Promise.resolve('/mock/appdata')), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + writeFile: vi.fn(), + readFile: vi.fn(), + exists: vi.fn(), + mkdir: vi.fn(), + readDir: vi.fn(), + remove: vi.fn(), +})); + +vi.mock('@/lib/services/ModCacheService', () => ({ + modCacheService: { + initialize: vi.fn(), + addGroupToCache: vi.fn(), + getCachePath: vi.fn(), + }, +})); + +vi.mock('@/lib/services/ProfileService', () => ({ + profileService: { + initialize: vi.fn(), + createProfile: vi.fn(), + addModToProfile: vi.fn(), + getAllProfiles: vi.fn(), + }, +})); + +vi.mock('@/lib/services/DiskPerformanceService', () => ({ + diskPerformanceService: { + getPoolSize: vi.fn(() => Promise.resolve(3)), + }, +})); + +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'mock-uuid-1234'), +})); + +import { invoke } from '@tauri-apps/api/core'; +import { modCacheService } from '@/lib/services/ModCacheService'; +import { profileService } from '@/lib/services/ProfileService'; + +describe('ExistingModScanService', () => { + let service: ExistingModScanService; + + beforeEach(() => { + service = new ExistingModScanService(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('scanAndImport', () => { + it('should return empty result when no mods found', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [], + total_files: 0, + total_groups: 0, + errors: [], + }); + + const progressCb = vi.fn(); + const result = await service.scanAndImport('/mock/Mods', progressCb); + + expect(result.imported).toBe(0); + expect(result.profileId).toBe(''); + expect(invoke).toHaveBeenCalledWith('scan_mods_folder', { + modsPath: '/mock/Mods', + }); + }); + + it('should create profile and import mods when found', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [ + { + group_name: 'WickedWhims', + is_folder: true, + files: [ + { path: '/Mods/WickedWhims/ww.package', file_name: 'ww.package', size: 1024 }, + { path: '/Mods/WickedWhims/ww.ts4script', file_name: 'ww.ts4script', size: 512 }, + ], + total_size: 1536, + combined_hash: 'abc123', + }, + { + group_name: 'loose_mod', + is_folder: false, + files: [ + { path: '/Mods/loose_mod.package', file_name: 'loose_mod.package', size: 256 }, + ], + total_size: 256, + combined_hash: 'def456', + }, + ], + total_files: 3, + total_groups: 2, + errors: [], + }); + + vi.mocked(profileService.getAllProfiles).mockResolvedValue([]); + vi.mocked(profileService.createProfile).mockResolvedValue({ + id: 'new-profile-id', + name: 'My Old Mods', + description: 'Auto-imported from existing Sims 4 Mods folder', + tags: ['imported'], + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + mods: [], + isActive: false, + }); + + vi.mocked(modCacheService.addGroupToCache).mockResolvedValue({ + fileHash: 'abc123', + modId: 'mock-uuid-1234', + fileName: 'WickedWhims', + fileSize: 1536, + downloadedAt: '2024-01-01', + usedByProfiles: ['new-profile-id'], + files: [], + }); + + const progressCb = vi.fn(); + const result = await service.scanAndImport('/mock/Mods', progressCb); + + expect(profileService.createProfile).toHaveBeenCalledWith( + 'My Old Mods', + 'Auto-imported from existing Sims 4 Mods folder', + ['imported'] + ); + expect(modCacheService.addGroupToCache).toHaveBeenCalledTimes(2); + expect(profileService.addModToProfile).toHaveBeenCalledTimes(2); + expect(result.imported).toBe(2); + expect(result.profileName).toBe('My Old Mods'); + expect(result.totalFiles).toBe(3); + }); + + it('should generate unique profile name when default exists', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [ + { + group_name: 'test', + is_folder: false, + files: [{ path: '/Mods/test.package', file_name: 'test.package', size: 100 }], + total_size: 100, + combined_hash: 'hash1', + }, + ], + total_files: 1, + total_groups: 1, + errors: [], + }); + + vi.mocked(profileService.getAllProfiles).mockResolvedValue([ + { + id: 'existing', + name: 'My Old Mods', + description: '', + tags: [], + createdAt: '', + updatedAt: '', + mods: [], + isActive: false, + }, + ]); + + vi.mocked(profileService.createProfile).mockResolvedValue({ + id: 'new-id', + name: 'My Old Mods (2)', + description: '', + tags: ['imported'], + createdAt: '', + updatedAt: '', + mods: [], + isActive: false, + }); + + vi.mocked(modCacheService.addGroupToCache).mockResolvedValue({ + fileHash: 'hash1', + modId: 'mock-uuid-1234', + fileName: 'test', + fileSize: 100, + downloadedAt: '', + usedByProfiles: ['new-id'], + files: [], + }); + + const result = await service.scanAndImport('/mock/Mods', vi.fn()); + + expect(profileService.createProfile).toHaveBeenCalledWith( + 'My Old Mods (2)', + expect.any(String), + ['imported'] + ); + }); + + it('should count skipped mods (already cached)', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [ + { + group_name: 'cached_mod', + is_folder: false, + files: [{ path: '/Mods/cached.package', file_name: 'cached.package', size: 100 }], + total_size: 100, + combined_hash: 'existing-hash', + }, + ], + total_files: 1, + total_groups: 1, + errors: [], + }); + + vi.mocked(profileService.getAllProfiles).mockResolvedValue([]); + vi.mocked(profileService.createProfile).mockResolvedValue({ + id: 'p-id', + name: 'My Old Mods', + description: '', + tags: [], + createdAt: '', + updatedAt: '', + mods: [], + isActive: false, + }); + + // usedByProfiles.length > 1 means it was already cached + vi.mocked(modCacheService.addGroupToCache).mockResolvedValue({ + fileHash: 'existing-hash', + modId: 'mock-uuid-1234', + fileName: 'cached_mod', + fileSize: 100, + downloadedAt: '', + usedByProfiles: ['other-profile', 'p-id'], + files: [], + }); + + const result = await service.scanAndImport('/mock/Mods', vi.fn()); + + expect(result.skipped).toBe(1); + expect(result.imported).toBe(0); + }); + + it('should handle scan errors gracefully', async () => { + vi.mocked(invoke).mockRejectedValue(new Error('Folder not found')); + + const result = await service.scanAndImport('/invalid/path', vi.fn()); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toContain('Folder not found'); + }); + + it('should report progress during scan and import phases', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [ + { + group_name: 'mod1', + is_folder: false, + files: [{ path: '/Mods/mod1.package', file_name: 'mod1.package', size: 100 }], + total_size: 100, + combined_hash: 'h1', + }, + ], + total_files: 1, + total_groups: 1, + errors: [], + }); + + vi.mocked(profileService.getAllProfiles).mockResolvedValue([]); + vi.mocked(profileService.createProfile).mockResolvedValue({ + id: 'p-id', + name: 'My Old Mods', + description: '', + tags: [], + createdAt: '', + updatedAt: '', + mods: [], + isActive: false, + }); + + vi.mocked(modCacheService.addGroupToCache).mockResolvedValue({ + fileHash: 'h1', + modId: 'mock-uuid-1234', + fileName: 'mod1', + fileSize: 100, + downloadedAt: '', + usedByProfiles: ['p-id'], + files: [], + }); + + const progressCb = vi.fn(); + await service.scanAndImport('/mock/Mods', progressCb); + + const phases = progressCb.mock.calls.map((c: any[]) => c[0].phase); + expect(phases).toContain('importing'); + expect(phases).toContain('complete'); + }); + + it('should set correct ProfileMod fields for folder groups', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [ + { + group_name: 'MCCC', + is_folder: true, + files: [ + { path: '/Mods/MCCC/mc_cmd.ts4script', file_name: 'mc_cmd.ts4script', size: 500 }, + { path: '/Mods/MCCC/mc_cas.package', file_name: 'mc_cas.package', size: 300 }, + ], + total_size: 800, + combined_hash: 'mccc-hash', + }, + ], + total_files: 2, + total_groups: 1, + errors: [], + }); + + vi.mocked(profileService.getAllProfiles).mockResolvedValue([]); + vi.mocked(profileService.createProfile).mockResolvedValue({ + id: 'p-id', + name: 'My Old Mods', + description: '', + tags: [], + createdAt: '', + updatedAt: '', + mods: [], + isActive: false, + }); + + vi.mocked(modCacheService.addGroupToCache).mockResolvedValue({ + fileHash: 'mccc-hash', + modId: 'mock-uuid-1234', + fileName: 'MCCC', + fileSize: 800, + downloadedAt: '', + usedByProfiles: ['p-id'], + files: [], + }); + + await service.scanAndImport('/mock/Mods', vi.fn()); + + expect(profileService.addModToProfile).toHaveBeenCalledWith( + 'p-id', + expect.objectContaining({ + localModId: 'mock-uuid-1234', + isLocal: true, + modName: 'MCCC', + fileHash: 'mccc-hash', + fileName: 'MCCC', + enabled: true, + cacheLocation: 'mccc-hash', + }) + ); + }); + + it('should set fileName to actual file name for loose files', async () => { + vi.mocked(invoke).mockResolvedValue({ + groups: [ + { + group_name: 'loose_mod', + is_folder: false, + files: [ + { path: '/Mods/loose_mod.package', file_name: 'loose_mod.package', size: 100 }, + ], + total_size: 100, + combined_hash: 'loose-hash', + }, + ], + total_files: 1, + total_groups: 1, + errors: [], + }); + + vi.mocked(profileService.getAllProfiles).mockResolvedValue([]); + vi.mocked(profileService.createProfile).mockResolvedValue({ + id: 'p-id', + name: 'My Old Mods', + description: '', + tags: [], + createdAt: '', + updatedAt: '', + mods: [], + isActive: false, + }); + + vi.mocked(modCacheService.addGroupToCache).mockResolvedValue({ + fileHash: 'loose-hash', + modId: 'mock-uuid-1234', + fileName: 'loose_mod', + fileSize: 100, + downloadedAt: '', + usedByProfiles: ['p-id'], + files: [], + }); + + await service.scanAndImport('/mock/Mods', vi.fn()); + + expect(profileService.addModToProfile).toHaveBeenCalledWith( + 'p-id', + expect.objectContaining({ + fileName: 'loose_mod.package', + }) + ); + }); + }); +}); diff --git a/app/tests/unit/ModCacheService.test.ts b/app/tests/unit/ModCacheService.test.ts new file mode 100644 index 0000000..3a0c6b4 --- /dev/null +++ b/app/tests/unit/ModCacheService.test.ts @@ -0,0 +1,297 @@ +/** + * Unit tests for ModCacheService + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ModCacheService } from '@/lib/services/ModCacheService'; + +vi.mock('@tauri-apps/plugin-fs', () => ({ + writeFile: vi.fn(), + readFile: vi.fn(), + exists: vi.fn(), + mkdir: vi.fn(), + readDir: vi.fn(), + remove: vi.fn(), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + join: vi.fn((...args: string[]) => args.join('/')), + basename: vi.fn((path: string) => path.split('/').pop() || ''), + appDataDir: vi.fn(() => Promise.resolve('/mock/appdata')), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +vi.mock('@/lib/services/DiskPerformanceService', () => ({ + diskPerformanceService: { + getPoolSize: vi.fn(() => Promise.resolve(3)), + }, +})); + +import { writeFile, readFile, exists, mkdir, readDir } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; + +describe('ModCacheService', () => { + let service: ModCacheService; + + const mockIndex = { + version: '1.0.0', + entries: {}, + lastCleanup: '2024-01-01', + }; + + beforeEach(async () => { + service = new ModCacheService(); + vi.clearAllMocks(); + + // Setup default mocks for initialization + vi.mocked(exists).mockResolvedValue(true); + vi.mocked(readFile).mockResolvedValue( + new TextEncoder().encode(JSON.stringify(mockIndex)) + ); + + await service.initialize(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('addToCache - non-ZIP handling', () => { + it('should copy .package files directly instead of extracting', async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string, args?: any) => { + if (cmd === 'calculate_file_hash') return 'hash-pkg'; + if (cmd === 'get_file_size') return 1024; + if (cmd === 'copy_file_to') return undefined; + return undefined; + }); + + vi.mocked(readDir).mockResolvedValue([ + { name: 'mod.package', isDirectory: false, isFile: true, isSymlink: false }, + ] as any); + + const result = await service.addToCache( + 'local-id', + 'mod.package', + '/path/to/mod.package', + 'profile-1' + ); + + expect(invoke).toHaveBeenCalledWith('copy_file_to', { + source: '/path/to/mod.package', + target: expect.stringContaining('mod.package'), + }); + expect(invoke).not.toHaveBeenCalledWith( + 'extract_zip', + expect.anything() + ); + expect(result.fileHash).toBe('hash-pkg'); + }); + + it('should copy .ts4script files directly instead of extracting', async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'calculate_file_hash') return 'hash-ts4'; + if (cmd === 'get_file_size') return 2048; + if (cmd === 'copy_file_to') return undefined; + return undefined; + }); + + vi.mocked(readDir).mockResolvedValue([ + { name: 'script.ts4script', isDirectory: false, isFile: true, isSymlink: false }, + ] as any); + + const result = await service.addToCache( + 'local-id', + 'script.ts4script', + '/path/to/script.ts4script', + 'profile-1' + ); + + expect(invoke).toHaveBeenCalledWith('copy_file_to', { + source: '/path/to/script.ts4script', + target: expect.stringContaining('script.ts4script'), + }); + }); + + it('should use extract_zip for .zip files', async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'calculate_file_hash') return 'hash-zip'; + if (cmd === 'get_file_size') return 4096; + if (cmd === 'extract_zip') return undefined; + return undefined; + }); + + vi.mocked(readDir).mockResolvedValue([]); + + await service.addToCache( + 'local-id', + 'mod.zip', + '/path/to/mod.zip', + 'profile-1' + ); + + expect(invoke).toHaveBeenCalledWith('extract_zip', { + zipPath: '/path/to/mod.zip', + destDir: expect.any(String), + }); + }); + }); + + describe('addGroupToCache', () => { + it('should copy all files and create cache entry', async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + + const result = await service.addGroupToCache( + 'mod-uuid', + 'WickedWhims', + ['/Mods/WickedWhims/ww.package', '/Mods/WickedWhims/ww.ts4script'], + 'combined-hash-abc', + 1536, + 'profile-1' + ); + + expect(mkdir).toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledWith('copy_file_to', { + source: '/Mods/WickedWhims/ww.package', + target: expect.stringContaining('ww.package'), + }); + expect(invoke).toHaveBeenCalledWith('copy_file_to', { + source: '/Mods/WickedWhims/ww.ts4script', + target: expect.stringContaining('ww.ts4script'), + }); + expect(result.fileHash).toBe('combined-hash-abc'); + expect(result.fileName).toBe('WickedWhims'); + expect(result.fileSize).toBe(1536); + expect(result.usedByProfiles).toContain('profile-1'); + expect(result.files).toHaveLength(2); + }); + + it('should return existing entry and add profile if already cached', async () => { + const existingEntry = { + fileHash: 'existing-hash', + modId: 'old-uuid', + fileName: 'MCCC', + fileSize: 800, + downloadedAt: '2024-01-01', + usedByProfiles: ['other-profile'], + files: [], + }; + + vi.mocked(readFile).mockResolvedValue( + new TextEncoder().encode( + JSON.stringify({ + ...mockIndex, + entries: { 'existing-hash': existingEntry }, + }) + ) + ); + + const result = await service.addGroupToCache( + 'new-uuid', + 'MCCC', + ['/Mods/MCCC/mc.package'], + 'existing-hash', + 800, + 'profile-2' + ); + + expect(result.usedByProfiles).toContain('other-profile'); + expect(result.usedByProfiles).toContain('profile-2'); + // Should NOT have called copy_file_to since it's already cached + expect(invoke).not.toHaveBeenCalledWith( + 'copy_file_to', + expect.anything() + ); + }); + + it('should not duplicate profile in usedByProfiles', async () => { + const existingEntry = { + fileHash: 'dup-hash', + modId: 'uuid-1', + fileName: 'Mod', + fileSize: 100, + downloadedAt: '2024-01-01', + usedByProfiles: ['profile-1'], + files: [], + }; + + vi.mocked(readFile).mockResolvedValue( + new TextEncoder().encode( + JSON.stringify({ + ...mockIndex, + entries: { 'dup-hash': existingEntry }, + }) + ) + ); + + const result = await service.addGroupToCache( + 'uuid-2', + 'Mod', + ['/Mods/mod.package'], + 'dup-hash', + 100, + 'profile-1' + ); + + expect( + result.usedByProfiles.filter((p: string) => p === 'profile-1') + ).toHaveLength(1); + }); + + it('should save metadata.json for new cache entries', async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + + await service.addGroupToCache( + 'mod-uuid', + 'TestMod', + ['/Mods/test.package'], + 'new-hash', + 500, + 'profile-1' + ); + + const writeFileCalls = vi.mocked(writeFile).mock.calls; + const metadataWrite = writeFileCalls.find((call) => + (call[0] as string).includes('metadata.json') + ); + expect(metadataWrite).toBeDefined(); + + const written = JSON.parse( + new TextDecoder().decode(metadataWrite![1] as Uint8Array) + ); + expect(written.fileHash).toBe('new-hash'); + expect(written.fileName).toBe('TestMod'); + }); + }); + + describe('findModFiles', () => { + it('should find both .package and .ts4script files', async () => { + vi.mocked(readDir).mockResolvedValue([ + { name: 'mod.package', isDirectory: false, isFile: true, isSymlink: false }, + { name: 'script.ts4script', isDirectory: false, isFile: true, isSymlink: false }, + { name: 'readme.txt', isDirectory: false, isFile: true, isSymlink: false }, + ] as any); + + // @ts-ignore - accessing private method for testing + const result = await service.findModFiles('/cache/dir'); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('mod.package'); + expect(result[1]).toContain('script.ts4script'); + }); + + it('should ignore non-mod files', async () => { + vi.mocked(readDir).mockResolvedValue([ + { name: 'readme.txt', isDirectory: false, isFile: true, isSymlink: false }, + { name: 'image.png', isDirectory: false, isFile: true, isSymlink: false }, + ] as any); + + // @ts-ignore + const result = await service.findModFiles('/cache/dir'); + + expect(result).toHaveLength(0); + }); + }); +});