Skip to content

Commit bc92d92

Browse files
committed
feat: enhance file system API with new features and improvements, including Date object support for timestamps, updated README, and additional tests for access and stat functions
1 parent 2922abc commit bc92d92

18 files changed

+398
-83
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,6 @@ Cargo.lock
132132

133133
# vibe coding 🤓
134134
.cursor/
135+
136+
# 本地开发备忘,不进入版本控制
137+
TODO.md

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ hyper-fs/
7676
│ ├── stat.rs # stat / lstat
7777
│ ├── read_file.rs # readFile / readFileSync
7878
│ ├── write_file.rs # writeFile / appendFile
79+
│ ├── cp.rs # cp / cpSync(递归复制,支持并发)
7980
│ └── ... # 每个 API 一个文件
8081
├── __test__/ # 测试文件(TypeScript, AVA 框架)
8182
│ ├── readdir.spec.ts
@@ -91,7 +92,8 @@ hyper-fs/
9192
│ ├── copy_file.ts # copyFile 性能对比
9293
│ ├── exists.ts # exists / access 性能对比
9394
│ ├── mkdir.ts # mkdir 性能对比
94-
│ └── rm.ts # rm 性能对比(含并发)
95+
│ ├── rm.ts # rm 性能对比(含并发)
96+
│ └── cp.ts # cp 性能对比(含并发,树形/平铺目录)
9597
├── reference/ # Node.js fs 模块源码参考
9698
│ ├── fs.js # Node.js 主 fs 模块
9799
│ └── internal/fs/ # Node.js 内部实现

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ version = "0.1.0"
99
crate-type = ["cdylib"]
1010

1111
[dependencies]
12+
chrono = { version = "0.4", features = ["clock"] }
1213
ignore = "0.4.25"
1314
jwalk = "0.8.1"
14-
napi = "3.0.0"
15+
napi = { version = "3.0.0", features = ["chrono_date"] }
1516
napi-derive = "3.4"
1617
rayon = "1.11.0"
1718
remove_dir_all = "1.0.0"

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ We are rewriting `fs` APIs one by one.
3535
```ts
3636
path: string; //
3737
options?: {
38-
encoding?: string; //
38+
encoding?: string; // 🚧 ('utf8' default; 'buffer' not supported)
3939
withFileTypes?: boolean; //
4040
recursive?: boolean; //
4141
concurrency?: number; //
@@ -154,7 +154,11 @@ We are rewriting `fs` APIs one by one.
154154
```ts
155155
path: string //
156156
```
157-
- **Return Type**: `Stats` (dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs + isFile/isDirectory/isSymbolicLink/...)
157+
- **Return Type**: `Stats`
158+
- Numeric fields: `dev`, `mode`, `nlink`, `uid`, `gid`, `rdev`, `blksize`, `ino`, `size`, `blocks`, `atimeMs`, `mtimeMs`, `ctimeMs`, `birthtimeMs`
159+
- **Date fields**: `atime`, `mtime`, `ctime`, `birthtime``Date` objects ✅
160+
- Methods: `isFile()`, `isDirectory()`, `isSymbolicLink()`, ...
161+
- **Error distinction**: `ENOENT` vs `EACCES`
158162

159163
### `lstat`
160164

@@ -281,6 +285,7 @@ We are rewriting `fs` APIs one by one.
281285
```ts
282286
target: string //
283287
path: string //
288+
type?: 'file' | 'dir' | 'junction' // ✅ (Windows only, ignored on Unix)
284289
```
285290

286291
### `link`
@@ -298,6 +303,7 @@ We are rewriting `fs` APIs one by one.
298303
prefix: string //
299304
```
300305
- **Return Type**: `string`
306+
- Uses OS-level random source (`/dev/urandom` on Unix, `BCryptGenRandom` on Windows) with up to 10 retries ✅
301307

302308
### `watch`
303309

README.zh-CN.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pnpm add hyper-fs
3737
```ts
3838
path: string; //
3939
options?: {
40-
encoding?: string; //
40+
encoding?: string; // 🚧(默认 'utf8';'buffer' 暂不支持)
4141
withFileTypes?: boolean; //
4242
recursive?: boolean; //
4343
concurrency?: number; //
@@ -156,7 +156,11 @@ pnpm add hyper-fs
156156
```ts
157157
path: string //
158158
```
159-
- **返回类型**`Stats`(dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs + isFile/isDirectory/isSymbolicLink/...)
159+
- **返回类型**`Stats`
160+
- 数值字段:`dev`, `mode`, `nlink`, `uid`, `gid`, `rdev`, `blksize`, `ino`, `size`, `blocks`, `atimeMs`, `mtimeMs`, `ctimeMs`, `birthtimeMs`
161+
- **Date 字段**`atime`, `mtime`, `ctime`, `birthtime``Date` 对象 ✅
162+
- 方法:`isFile()`, `isDirectory()`, `isSymbolicLink()`, ...
163+
- **错误区分**`ENOENT` vs `EACCES`
160164

161165
### `lstat`
162166

@@ -283,6 +287,7 @@ pnpm add hyper-fs
283287
```ts
284288
target: string //
285289
path: string //
290+
type?: 'file' | 'dir' | 'junction' // ✅(仅 Windows 有效,Unix 忽略)
286291
```
287292

288293
### `link`
@@ -300,6 +305,7 @@ pnpm add hyper-fs
300305
prefix: string //
301306
```
302307
- **返回类型**`string`
308+
- 使用系统随机源(Unix: `/dev/urandom`,Windows: `BCryptGenRandom`),最多重试 10 次 ✅
303309

304310
### `watch`
305311

__test__/access.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import test from 'ava'
22
import * as nodeFs from 'node:fs'
33
import { accessSync, access } from '../index.js'
4+
import { join } from 'node:path'
5+
import { tmpdir } from 'node:os'
46

57
const F_OK = 0
68
const R_OK = 4
79
const W_OK = 2
10+
const X_OK = 1
11+
12+
function tmpFile(name: string): string {
13+
const dir = join(tmpdir(), `hyper-fs-test-access-${Date.now()}-${Math.random().toString(36).slice(2)}`)
14+
nodeFs.mkdirSync(dir, { recursive: true })
15+
const file = join(dir, name)
16+
nodeFs.writeFileSync(file, 'test')
17+
return file
18+
}
819

920
test('accessSync: should succeed for existing file (F_OK)', (t) => {
1021
t.notThrows(() => accessSync('./package.json'))
@@ -72,3 +83,36 @@ test('dual-run: accessSync should both throw for non-existent file', (t) => {
7283

7384
t.is(hyperThrew, nodeThrew)
7485
})
86+
87+
test('accessSync: X_OK should succeed for executable file', (t) => {
88+
if (process.platform === 'win32') {
89+
t.pass('Skipping X_OK test on Windows')
90+
return
91+
}
92+
const file = tmpFile('exec.sh')
93+
nodeFs.chmodSync(file, 0o755)
94+
t.notThrows(() => accessSync(file, X_OK))
95+
})
96+
97+
test('accessSync: should throw ENOENT (not EACCES) for missing file', (t) => {
98+
const target = '/tmp/no-such-file-access-' + Date.now()
99+
t.throws(() => accessSync(target), { message: /ENOENT/ })
100+
})
101+
102+
test('dual-run: accessSync ENOENT error message starts with ENOENT like node:fs', (t) => {
103+
const target = '/tmp/no-such-file-access-dual-' + Date.now()
104+
let nodeMsg = ''
105+
let hyperMsg = ''
106+
try {
107+
nodeFs.accessSync(target)
108+
} catch (e) {
109+
nodeMsg = (e as Error).message
110+
}
111+
try {
112+
accessSync(target)
113+
} catch (e) {
114+
hyperMsg = (e as Error).message
115+
}
116+
t.true(nodeMsg.startsWith('ENOENT'))
117+
t.true(hyperMsg.startsWith('ENOENT'))
118+
})

__test__/read_file.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,16 @@ test('readFile: async should read file', async (t) => {
3535
test('readFile: async should throw on non-existent file', async (t) => {
3636
await t.throwsAsync(async () => await readFile('./no-such-file'), { message: /ENOENT/ })
3737
})
38+
39+
test('dual-run: readFileSync Buffer should match node:fs byte-for-byte', (t) => {
40+
const nodeResult = nodeFs.readFileSync('./package.json')
41+
const hyperResult = readFileSync('./package.json') as Buffer
42+
t.true(Buffer.isBuffer(hyperResult))
43+
t.deepEqual(hyperResult, nodeResult)
44+
})
45+
46+
test('dual-run: readFileSync utf8 string should match node:fs', (t) => {
47+
const nodeResult = nodeFs.readFileSync('./package.json', 'utf8')
48+
const hyperResult = readFileSync('./package.json', { encoding: 'utf8' }) as string
49+
t.is(hyperResult, nodeResult)
50+
})

__test__/realpath.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import test from 'ava'
22
import { realpathSync, realpath } from '../index.js'
33
import * as nodeFs from 'node:fs'
44
import * as path from 'node:path'
5+
import { join } from 'node:path'
6+
import { tmpdir } from 'node:os'
7+
8+
function tmpDir(): string {
9+
const dir = join(tmpdir(), `hyper-fs-test-realpath-${Date.now()}-${Math.random().toString(36).slice(2)}`)
10+
nodeFs.mkdirSync(dir, { recursive: true })
11+
return dir
12+
}
513

614
test('realpathSync: should resolve to absolute path', (t) => {
715
const result = realpathSync('.')
@@ -26,3 +34,37 @@ test('realpath: async should resolve path', async (t) => {
2634
test('realpath: async should throw on non-existent path', async (t) => {
2735
await t.throwsAsync(async () => await realpath('./no-such-path'), { message: /ENOENT/ })
2836
})
37+
38+
test('dual-run: realpathSync should resolve symlink to real path', (t) => {
39+
const dir = tmpDir()
40+
const target = join(dir, 'real-target.txt')
41+
const link = join(dir, 'link.txt')
42+
nodeFs.writeFileSync(target, 'hello')
43+
nodeFs.symlinkSync(target, link)
44+
45+
const nodeResult = nodeFs.realpathSync(link)
46+
const hyperResult = realpathSync(link)
47+
// Compare against node:fs (not raw `target`): on macOS /tmp is a symlink to /private/tmp,
48+
// so realpath resolves through it.
49+
t.is(hyperResult, nodeResult)
50+
// The resolved path should end with the target filename
51+
t.true(hyperResult.endsWith('real-target.txt'))
52+
})
53+
54+
test('dual-run: realpathSync should resolve relative path same as node:fs', (t) => {
55+
const nodeResult = nodeFs.realpathSync('src')
56+
const hyperResult = realpathSync('src')
57+
t.is(hyperResult, nodeResult)
58+
})
59+
60+
test('realpath: async dual-run should resolve symlink same as node:fs', async (t) => {
61+
const dir = tmpDir()
62+
const target = join(dir, 'async-target.txt')
63+
const link = join(dir, 'async-link.txt')
64+
nodeFs.writeFileSync(target, 'hello')
65+
nodeFs.symlinkSync(target, link)
66+
67+
const nodeResult = nodeFs.realpathSync(link)
68+
const hyperResult = await realpath(link)
69+
t.is(hyperResult, nodeResult)
70+
})

__test__/stat.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import test from 'ava'
22
import { statSync, stat, lstatSync, lstat } from '../index.js'
33
import * as nodeFs from 'node:fs'
4+
import { join } from 'node:path'
5+
import { tmpdir } from 'node:os'
6+
7+
function tmpDir(): string {
8+
const dir = join(tmpdir(), `hyper-fs-test-stat-${Date.now()}-${Math.random().toString(36).slice(2)}`)
9+
nodeFs.mkdirSync(dir, { recursive: true })
10+
return dir
11+
}
412

513
test('statSync: should return stats for a file', (t) => {
614
const s = statSync('./package.json')
@@ -62,3 +70,55 @@ test('statSync: atimeMs/mtimeMs/ctimeMs/birthtimeMs should be numbers', (t) => {
6270
t.is(typeof s.birthtimeMs, 'number')
6371
t.true(s.mtimeMs > 0)
6472
})
73+
74+
test('statSync: atime/mtime/ctime/birthtime should be Date objects', (t) => {
75+
const s = statSync('./package.json')
76+
t.true(s.atime instanceof Date)
77+
t.true(s.mtime instanceof Date)
78+
t.true(s.ctime instanceof Date)
79+
t.true(s.birthtime instanceof Date)
80+
t.true(s.mtime.getTime() > 0)
81+
})
82+
83+
test('statSync: atime.getTime() should be close to atimeMs', (t) => {
84+
const s = statSync('./package.json')
85+
t.true(Math.abs(s.atime.getTime() - s.atimeMs) < 1000)
86+
})
87+
88+
test('statSync: should match node:fs atime/mtime Date values', (t) => {
89+
const nodeStat = nodeFs.statSync('./package.json')
90+
const hyperStat = statSync('./package.json')
91+
t.is(hyperStat.mtime.getTime(), nodeStat.mtime.getTime())
92+
})
93+
94+
test('lstatSync: dual-run — symlink should report isSymbolicLink()', (t) => {
95+
const dir = tmpDir()
96+
const target = join(dir, 'target.txt')
97+
const link = join(dir, 'link.txt')
98+
nodeFs.writeFileSync(target, 'hello')
99+
nodeFs.symlinkSync(target, link)
100+
101+
const nodeLstat = nodeFs.lstatSync(link)
102+
const hyperLstat = lstatSync(link)
103+
104+
t.is(hyperLstat.isSymbolicLink(), nodeLstat.isSymbolicLink())
105+
t.true(hyperLstat.isSymbolicLink())
106+
t.is(hyperLstat.isFile(), nodeLstat.isFile())
107+
t.false(hyperLstat.isFile())
108+
})
109+
110+
test('statSync: dual-run — stat follows symlink (shows target not link)', (t) => {
111+
const dir = tmpDir()
112+
const target = join(dir, 'target.txt')
113+
const link = join(dir, 'link.txt')
114+
nodeFs.writeFileSync(target, 'hello')
115+
nodeFs.symlinkSync(target, link)
116+
117+
const nodeStat = nodeFs.statSync(link)
118+
const hyperStat = statSync(link)
119+
120+
t.is(hyperStat.isFile(), nodeStat.isFile())
121+
t.true(hyperStat.isFile())
122+
t.is(hyperStat.isSymbolicLink(), nodeStat.isSymbolicLink())
123+
t.false(hyperStat.isSymbolicLink())
124+
})

__test__/symlink.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import test from 'ava'
22
import { symlinkSync, symlink, readlinkSync, statSync, lstatSync } from '../index.js'
3+
import * as nodeFs from 'node:fs'
34
import { writeFileSync, mkdirSync, existsSync, readlinkSync as nodeReadlinkSync } from 'node:fs'
45
import { join } from 'node:path'
56
import { tmpdir } from 'node:os'
@@ -58,6 +59,30 @@ test('symlinkSync: should throw EEXIST if link path already exists', (t) => {
5859
t.throws(() => symlinkSync(target, link), { message: /EEXIST/ })
5960
})
6061

62+
// ===== dual-run =====
63+
64+
test('dual-run: symlinkSync result should match node:fs.symlinkSync', (t) => {
65+
const dir = tmpDir()
66+
const target = join(dir, 'dual-target.txt')
67+
const hyperLink = join(dir, 'dual-hyper-link.txt')
68+
const nodeLink = join(dir, 'dual-node-link.txt')
69+
nodeFs.writeFileSync(target, 'hello')
70+
71+
nodeFs.symlinkSync(target, nodeLink)
72+
symlinkSync(target, hyperLink)
73+
74+
// Both should be symlinks pointing to the same target
75+
const nodeReadlink = nodeReadlinkSync(nodeLink, 'utf8')
76+
const hyperReadlink = nodeReadlinkSync(hyperLink, 'utf8')
77+
t.is(hyperReadlink, nodeReadlink)
78+
79+
// Both should resolve to the same file
80+
const nodeLstat = nodeFs.lstatSync(nodeLink)
81+
const hyperLstat = nodeFs.lstatSync(hyperLink)
82+
t.is(hyperLstat.isSymbolicLink(), nodeLstat.isSymbolicLink())
83+
t.true(hyperLstat.isSymbolicLink())
84+
})
85+
6186
// ===== async =====
6287

6388
test('symlink: async should create a symbolic link', async (t) => {

0 commit comments

Comments
 (0)