Welcome to contributing to rush-fs! This document walks you through environment setup, project structure, implementing new APIs, writing tests, and opening a PR.
- Environment setup
- Project structure
- Implementing a new API (full flow)
- Referencing Node.js source
- Writing Rust implementations
- Performance: parallelism
- Writing tests
- Running benchmarks
- Code style and commit conventions
- CI
| Tool | Version | Purpose |
|---|---|---|
| Node.js | >= 20 | Run tests and build scripts |
| pnpm | >= 9 | Package manager |
| Rust | stable (via rustup) | Build native module |
| rustup | Latest | Rust toolchain manager |
# 1. Clone the repo
git clone <repo-url>
cd rush-fs
# 2. Ensure Rust toolchain is ready
rustup default stable
# 3. Install Node.js dependencies
pnpm install
# 4. Build native module (debug mode for development)
pnpm build:debug
# 5. Run tests to confirm environment
pnpm testNote: Always use the scripts defined in
package.json; do not runcargo buildornapi builddirectly. napi-rs requires specific arguments to produce the correct.nodebinary and type declarations.
pnpm build:debug # Dev build (no optimizations, fast compile)
pnpm build # Release build (LTO, slower compile, faster output)
pnpm test # Run all tests (AVA)
pnpm bench # Run all benchmarks
pnpm bench readdir # Run only readdir benchmarks
pnpm lint # Lint (oxlint)
pnpm format # Format all code (Prettier + cargo fmt + taplo)rush-fs/
├── src/ # Rust source (core implementation)
│ ├── lib.rs # Module registration entry
│ ├── types.rs # Shared types (Dirent, Stats)
│ ├── utils.rs # Utilities (file type checks, etc.)
│ ├── readdir.rs # readdir / readdirSync
│ ├── stat.rs # stat / lstat
│ ├── read_file.rs # readFile / readFileSync
│ ├── write_file.rs # writeFile / appendFile
│ ├── cp.rs # cp / cpSync (recursive copy, concurrent)
│ └── ... # One file per API
├── __test__/ # Test files (TypeScript, AVA)
│ ├── readdir.spec.ts
│ ├── stat.spec.ts
│ └── ...
├── benchmark/ # Performance benchmarks
│ ├── bench.ts # Benchmark entry (auto-discovers and runs)
│ ├── readdir.ts # readdir benchmarks
│ ├── glob.ts # glob benchmarks
│ ├── stat.ts # stat / lstat benchmarks
│ ├── read_file.ts # readFile benchmarks (various sizes)
│ ├── write_file.ts # writeFile / appendFile benchmarks
│ ├── copy_file.ts # copyFile benchmarks
│ ├── exists.ts # exists / access benchmarks
│ ├── mkdir.ts # mkdir benchmarks
│ ├── rm.ts # rm benchmarks (including concurrency)
│ └── cp.ts # cp benchmarks (concurrency, tree/flat dirs)
├── reference/ # Node.js fs source reference
│ ├── fs.js # Node.js main fs module
│ └── internal/fs/ # Node.js internal implementation
├── index.js # napi-rs generated JS loader
├── index.d.ts # napi-rs generated type declarations
├── Cargo.toml # Rust dependencies
└── package.json # Node.js project config
- napi-rs — Rust ↔ Node.js bindings; JS glue is generated via macros
- jwalk — Parallel directory traversal (readdir recursive)
- ignore — Glob matching + .gitignore support
- rayon — Data parallelism (e.g. rm concurrency)
- AVA — Test framework (TypeScript, ESM)
- mitata — Micro-benchmark library
Using symlink as an example.
In reference/, look up the Node.js implementation and understand:
- Signature: Argument types, options, return value
- Edge behavior: Empty path? Missing file? Permission errors?
- Error format: Node uses messages like
ENOENT: no such file or directory, symlink 'xxx' -> 'yyy'
# Find symlink in Node.js
# reference/fs.js — search for "function symlink"
# reference/internal/fs/promises.js — search for "async function symlink"Create symlink.rs under src/ following this pattern:
use napi::bindgen_prelude::*;
use napi::Task;
use napi_derive::napi;
use std::path::Path;
// 1. Internal implementation (not exposed to JS)
fn symlink_impl(target: String, path: String) -> Result<()> {
// Implementation...
// Match Node.js error format:
// "ENOENT: no such file or directory, symlink 'target' -> 'path'"
Ok(())
}
// 2. Sync export
#[napi(js_name = "symlinkSync")]
pub fn symlink_sync(target: String, path: String) -> Result<()> {
symlink_impl(target, path)
}
// 3. Async export (AsyncTask)
pub struct SymlinkTask {
pub target: String,
pub path: String,
}
impl Task for SymlinkTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
symlink_impl(self.target.clone(), self.path.clone())
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
Ok(())
}
}
#[napi(js_name = "symlink")]
pub fn symlink(target: String, path: String) -> AsyncTask<SymlinkTask> {
AsyncTask::new(SymlinkTask { target, path })
}- Options: Use
#[napi(object)]andOption<T>fields - Polymorphic return: Use
Either<A, B>(e.g.string[] | Dirent[]) - Error prefix: Match Node.js style (
ENOENT:,EACCES:,EEXIST:, etc.) - Platform differences: Use
#[cfg(unix)]/#[cfg(not(unix))]
In src/lib.rs, add (alphabetically):
pub mod symlink; // In mod declarations
pub use symlink::*; // In use declarationspnpm build:debugAfter a successful build, index.d.ts is updated and the new function’s types are generated.
The reference/ directory holds key files copied from the Node.js repo:
| File | Content |
|---|---|
reference/fs.js |
All fs API callback/sync implementations |
reference/internal/fs/utils.js |
Stats construction, validation, errors, constants |
reference/internal/fs/promises.js |
Promise-based implementations (for async APIs) |
reference/internal/fs/dir.js |
opendir / Dir implementation |
reference/internal/fs/watchers.js |
watch / watchFile implementation |
Before implementing any API, search for the function name in these files and understand behavior and edge cases.
rush-fs uses Rust’s parallelism for heavy operations. Common approaches:
Used for readdir recursive:
use jwalk::{Parallelism, WalkDir};
let walk = WalkDir::new(path)
.parallelism(Parallelism::RayonNewPool(concurrency));Used for concurrent rm:
use rayon::prelude::*;
entries.par_iter().try_for_each(|entry| {
remove_recursive(&entry.path(), opts)
})?;Used for glob:
use ignore::WalkBuilder;
let mut builder = WalkBuilder::new(&cwd);
builder
.overrides(overrides)
.threads(concurrency);
builder.build_parallel().run(/* ... */);- Choose sensible default
concurrency(e.g. 4 or auto); allow overrides - For small workloads, parallelism overhead may dominate; validate with benchmarks
- Use
Arc<Mutex<Vec<T>>>(or similar) to collect results; keep lock scope small
One test file per API: __test__/<api_name>.spec.ts
AVA; TypeScript is compiled via @oxc-node/core. Tests run in ESM — use import only, not require().
import test from 'ava'
import { symlinkSync, symlink } from '../index.js'
import { existsSync, mkdirSync, readlinkSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
function tmpDir(): string {
const dir = join(tmpdir(), `rush-fs-test-symlink-${Date.now()}-${Math.random().toString(36).slice(2)}`)
mkdirSync(dir, { recursive: true })
return dir
}
// Sync tests
test('symlinkSync: should create a symbolic link', (t) => {
// ...
})
test('symlinkSync: should throw on non-existent target', (t) => {
t.throws(() => symlinkSync('/no/such/path', dest), { message: /ENOENT/ })
})
// Async tests
test('symlink: async should create a symbolic link', async (t) => {
await symlink(target, dest)
t.true(existsSync(dest))
})
// Parity with node:fs (important)
test('symlinkSync: should match node:fs behavior', (t) => {
const nodeResult = nodeFs.readlinkSync(link)
const hyperResult = readlinkSync(link)
t.is(hyperResult, nodeResult)
})Verify correct behavior for sync and async in normal cases.
Call both node:fs and rush-fs and compare results. Essential for API compatibility:
import * as nodeFs from 'node:fs'
import { statSync } from '../index.js'
test('statSync: should match node:fs stat values', (t) => {
const nodeStat = nodeFs.statSync('./package.json')
const hyperStat = statSync('./package.json')
t.is(hyperStat.size, nodeStat.size)
t.is(hyperStat.mode, nodeStat.mode)
t.is(hyperStat.isFile(), nodeStat.isFile())
t.is(hyperStat.isDirectory(), nodeStat.isDirectory())
})Assert error message format matches Node.js (ENOENT, EACCES, EEXIST, etc.):
test('should throw ENOENT on missing file', (t) => {
t.throws(() => someSync('./no-such-file'), { message: /ENOENT/ })
})
test('async should throw ENOENT on missing file', async (t) => {
await t.throwsAsync(async () => await someAsync('./no-such-file'), { message: /ENOENT/ })
})pnpm test # All tests
npx ava __test__/stat.spec.ts # Single fileBenchmarks live in benchmark/. Read-only operations (stat, readFile, exists) use mitata for micro-benchmarks; destructive or side-effectful ones (writeFile, copyFile, mkdir, rm) use manual iterations and process.hrtime, with test data recreated per run.
| File | APIs covered | Mode |
|---|---|---|
readdir.ts |
readdir (names / withFileTypes / recursive / concurrency) | mitata |
glob.ts |
glob vs node-glob vs fast-glob | mitata |
stat.ts |
stat / lstat / batch stat | mitata |
read_file.ts |
readFile (11B / 64KB / 4MB, Buffer / utf8) | mitata |
exists.ts |
exists / access / batch exists | mitata |
write_file.ts |
writeFile / appendFile (various sizes) | manual |
copy_file.ts |
copyFile (11B / 64KB / 4MB) | manual |
mkdir.ts |
mkdir (single / recursive / already exists) | manual |
rm.ts |
rm (flat / deep / tree + concurrency) | manual |
pnpm bench # All benchmarks
pnpm bench readdir # Only readdir
pnpm bench stat
pnpm bench read_file
pnpm bench globCreate benchmark/<api_name>.ts:
import { run, bench, group } from 'mitata'
import * as fs from 'node:fs'
import { someSync } from '../index.js'
group('Some API', () => {
bench('Node.js', () => fs.someSync(args)).baseline()
bench('Rush-FS', () => someSync(args))
})
group('Rush-FS Concurrency', () => {
bench('Default', () => someSync(args)).baseline()
bench('4 Threads', () => someSync(args, { concurrency: 4 }))
bench('8 Threads', () => someSync(args, { concurrency: 8 }))
})
await run({ colors: true })- Use a release build (
pnpm build), notpnpm build:debug - Mark Node.js as
.baseline()for comparison - Prefer real-world data (e.g.
node_modules) where useful - mitata warms up automatically; for manual benches, run a warmup first
- Indent: 2 spaces (
rustfmt.toml) - Format:
pnpm format:rs(same ascargo fmt) - Lint:
cargo clippy(also run in CI) #![deny(clippy::all)]is enabled inlib.rs
- Format:
pnpm format:prettier - Rules: 120 chars, no semicolons, single quotes, trailing commas
- Lint:
pnpm lint(oxlint)
git checkout -b feat/add-symlink
pnpm build:debug
pnpm test
pnpm format
git add .
git commit -m "feat: add symlink/symlinkSync"
# Optional: attach benchmark results in PR
pnpm build
pnpm bench(husky + lint-staged will format staged files on commit.)
- New
.rsfile undersrc/ - Module registered in
src/lib.rs -
pnpm build:debugpasses with no warnings - Tests in
__test__/(functional + parity + error cases) -
pnpm testpasses - README.md and README.zh-CN.md Roadmap updated
- Docs: When adding or changing an API, add or update the corresponding page under
docs/content/api/(see Documentation and.cursor/rules/docs-conventions.mdc). Runpnpm benchfor the Performance section and use table(s) with at least Node.jsfsas baseline. - (If applicable) Benchmark added and results included in PR
- Every supported API must have a doc page under
docs/content/api/. The docs site (Nextra) is in thedocs/directory; runpnpm doc:devfrom the repo root to preview. - When you add or change an API, add or update the corresponding file (e.g.
docs/content/api/readdir.mdx) and register it indocs/content/api/_meta.js. Each API page must include: Basic usage, Methods (signatures and options), Performance (data frompnpm bench, in table form, at least vs Node.jsfs), and Notes (known issues, tips). See.cursor/rules/docs-conventions.mdcfor the full convention. - Keep docs in sync: If you change behavior or options, update the API doc and the README roadmap so the docs stay accurate.
The docs are deployed with Vercel’s built-in Git integration (no custom CI):
- In Vercel, import the GitHub repo.
- Set Root Directory to
docs(the Next.js app lives there). - Leave Framework Preset as Next.js; build/install commands are set in
docs/vercel.json(pnpm). - Deploy. Every push to the connected branch will trigger a new build and deploy; previews are created for PRs if enabled.
To deploy elsewhere (e.g. Netlify, self-hosted), run pnpm doc:build and use the output in docs/.next (or run pnpm doc:start in a Node server). A custom CI job can run pnpm doc:build and upload artifacts if you need automation outside Vercel.
GitHub Actions on push/PR:
- Lint — oxlint,
cargo fmt --check,cargo clippy - Build — Cross-platform (macOS x64/arm64, Windows x64, Linux x64)
- Test — Tests on macOS, Windows, Linux (Node 20 & 22)
- Publish — Triggered by version tags; see Release workflow
For local development, pnpm build:debug and pnpm test are enough; CI handles cross-platform checks.
When cutting a new version (before running the Release workflow):
- Bump version in both places (must stay in sync):
package.json→"version": "x.y.z"Cargo.toml→version = "x.y.z"- npm does not allow re-publishing the same version; if a previous run partially published (e.g. 0.0.4 already on npm), bump to the next version (e.g. 0.0.5) and release again.
- Update CHANGELOG.md: move items from [Unreleased] into a new
## [x.y.z] - YYYY-MM-DDsection, and add the version link at the bottom ([x.y.z]: https://github.com/CoderSerio/rush-fs/compare/vA.B.C...vx.y.z). - Run Release: push to
main, then either Actions → Release → Run workflow orgit tag vx.y.z && git push origin vx.y.z.