Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: test

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.8

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Install frontend dependencies
run: bun ci --ignore-scripts

- name: Frontend tests
run: bun run test

- name: Rust tests
run: bun run test:rust
207 changes: 105 additions & 102 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,102 +1,105 @@
{
"name": "shooter",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"prepare": "lefthook install",
"dev": "vite",
"dev:app": "tauri dev",
"dev:e2e": "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222 tauri dev",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"extract": "lingui extract",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@lingui/core": "^5.9.0",
"@lingui/react": "^5.9.0",
"@tanstack/react-router": "^1.158.1",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-log": "~2.8.0",
"@tauri-apps/plugin-opener": "~2.5.3",
"ahooks": "^3.9.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jotai": "^2.17.1",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remeda": "^2.33.4",
"semver": "^7.7.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@storybook/react-vite": "^9.1.10",
"@faker-js/faker": "^10.2.0",
"@lingui/cli": "^5.9.0",
"@lingui/vite-plugin": "^5.9.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.158.1",
"@tauri-apps/cli": "^2.10.0",
"@types/bun": "^1.3.8",
"@types/react": "^19.2.11",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.3",
"@vitejs/plugin-react-swc": "^4.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"jiti": "^2.6.1",
"lefthook": "^2.1.0",
"prettier": "^3.8.1",
"shadcn": "^3.8.3",
"storybook": "^9.1.10",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"unplugin-auto-import": "^21.0.0",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.5"
},
"prettier": {
"semi": false,
"singleQuote": true
},
"lingui": {
"sourceLocale": "en-US",
"locales": [
"en-US"
],
"fallbackLocales": {
"default": "en-US"
},
"catalogs": [
{
"path": "locales/{locale}",
"include": [
"src"
]
}
],
"compileNamespace": "ts"
},
"packageManager": "bun@1.3.8",
"trustedDependencies": [
"@swc/core"
]
}
{
"name": "shooter",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"prepare": "lefthook install",
"dev": "vite",
"dev:app": "tauri dev",
"dev:e2e": "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222 tauri dev",
"build": "tsc && vite build",
"test": "bun test",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
"test:all": "bun run test && bun run test:rust",
"preview": "vite preview",
"tauri": "tauri",
"extract": "lingui extract",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@lingui/core": "^5.9.0",
"@lingui/react": "^5.9.0",
"@tanstack/react-router": "^1.158.1",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-log": "~2.8.0",
"@tauri-apps/plugin-opener": "~2.5.3",
"ahooks": "^3.9.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jotai": "^2.17.1",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remeda": "^2.33.4",
"semver": "^7.7.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@storybook/react-vite": "^9.1.10",
"@faker-js/faker": "^10.2.0",
"@lingui/cli": "^5.9.0",
"@lingui/vite-plugin": "^5.9.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.158.1",
"@tauri-apps/cli": "^2.10.0",
"@types/bun": "^1.3.8",
"@types/react": "^19.2.11",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.3",
"@vitejs/plugin-react-swc": "^4.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"jiti": "^2.6.1",
"lefthook": "^2.1.0",
"prettier": "^3.8.1",
"shadcn": "^3.8.3",
"storybook": "^9.1.10",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"unplugin-auto-import": "^21.0.0",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.5"
},
"prettier": {
"semi": false,
"singleQuote": true
},
"lingui": {
"sourceLocale": "en-US",
"locales": [
"en-US"
],
"fallbackLocales": {
"default": "en-US"
},
"catalogs": [
{
"path": "locales/{locale}",
"include": [
"src"
]
}
],
"compileNamespace": "ts"
},
"packageManager": "bun@1.3.8",
"trustedDependencies": [
"@swc/core"
]
}
58 changes: 25 additions & 33 deletions src-tauri/src/core/mod_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;

// Internal cache representation: includes files but NOT sent to frontend
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ModFS {
Expand All @@ -17,32 +16,27 @@ pub struct ModFS {
}

pub fn resolve_id(spt_paths: &SPTPathRules, files: &[Utf8PathBuf]) -> Result<String, SError> {
// 1. Single-pass collection using BTreeSet for automatic sorting
let ids: std::collections::BTreeSet<String> = files
.iter()
.filter_map(|path| {
// Server check
if let Ok(rel) = path.strip_prefix(&spt_paths.server_mods) {
return rel.components().next().map(|c| c.as_str().to_string());
}

// Client check (DLLs only)
if path.extension() == Some("dll")
&& let Ok(rel) = path.strip_prefix(&spt_paths.client_plugins)
{
// Normalize path separators to forward slashes for consistent hashing
return Some(rel.as_str().replace('\\', "/").to_string());
}

None // Ignore file if it matches neither
None
})
.collect();

if ids.is_empty() {
return Err(SError::UnableToDetermineModId);
}

// 2. Concatenate sorted IDs and hash the result
let concatenated = ids.into_iter().collect::<Vec<_>>().join("").to_lowercase();
Ok(hash_id(&concatenated))
}
Expand All @@ -59,39 +53,37 @@ pub fn infer_mod_type(files: &[Utf8PathBuf], config: &SPTPathRules) -> ModType {
}
}

fn collect_files(base: &Utf8Path) -> (Vec<Utf8PathBuf>, Vec<Utf8PathBuf>) {
WalkDir::new(base)
.into_iter()
// 1. Convert Result<DirEntry> to Option<DirEntry>
.filter_map(Result::ok)
// 2. Filter for files only
.filter(|e| e.path().is_file())
// 3. Transform to Utf8PathBuf and strip prefix using Result/Option combinators
.filter_map(|entry| {
Utf8PathBuf::from_path_buf(entry.path().to_path_buf())
.ok()
.and_then(|path| path.strip_prefix(base).ok().map(|p| p.to_path_buf()))
})
// 4. Fold into a tuple of (AllFiles, Executables)
.fold((Vec::new(), Vec::new()), |(mut all, mut exes), path| {
// Use Option::filter to handle the conditional push without an "if"
path.extension()
.filter(|&ext| ext == "exe")
.inspect(|_| exes.push(path.clone()));

all.push(path);
(all, exes)
})
fn collect_files(base: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>), SError> {
let mut files = Vec::new();
let mut executables = Vec::new();

for entry in WalkDir::new(base) {
let entry = entry.map_err(|e| SError::IOError(e.to_string()))?;
if !entry.file_type().is_file() {
continue;
}

let path = Utf8Path::from_path(entry.path())
.ok_or_else(|| SError::ParseError(format!("Invalid UTF-8 path: {:?}", entry.path())))?;
let path = path.strip_prefix(base)?.to_path_buf();

if path.extension() == Some("exe") {
executables.push(path.clone());
}

files.push(path);
}

Ok((files, executables))
}

/// Scans a mod root on disk and resolves it into a ModFS.
pub fn scan(root: &Utf8Path, spt_paths: &SPTPathRules) -> Result<ModFS, SError> {
let (files, executables) = collect_files(root); // Call once
let (files, executables) = collect_files(root)?;

Ok(ModFS {
id: resolve_id(spt_paths, &files)?,
mod_type: infer_mod_type(&files, spt_paths),
files, // Use the same vector
files,
executables,
})
}
Loading
Loading