Skip to content

Commit c434416

Browse files
yangshunclaude
andcommitted
Extract formatters and utils into separate modules
Move formatAsText into src/formats/text.ts, formatAsJson into src/formats/json.ts, and formatPermissions into src/utils/format-permissions.ts. Add unit tests for each module. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2a0ff2d commit c434416

7 files changed

Lines changed: 511 additions & 137 deletions

File tree

‎src/formats/json.test.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import type { RequiredOptions, TreeNode } from '../index';
4+
import { formatAsJson } from './json';
5+
6+
const DEFAULT_OPTIONS: RequiredOptions = {
7+
allFiles: false,
8+
date: false,
9+
dirsFirst: false,
10+
dirsOnly: false,
11+
filesFirst: false,
12+
fullPath: false,
13+
noIndent: false,
14+
permissions: false,
15+
json: false,
16+
prune: false,
17+
quote: false,
18+
sizes: false,
19+
exclude: [],
20+
maxDepth: Number.POSITIVE_INFINITY,
21+
reverse: false,
22+
sortBy: 'name',
23+
trailingSlash: false,
24+
lineAscii: false,
25+
unsorted: false,
26+
};
27+
28+
function opts(overrides: Partial<RequiredOptions> = {}): RequiredOptions {
29+
return { ...DEFAULT_OPTIONS, ...overrides };
30+
}
31+
32+
const MTIME = new Date('2025-01-15T10:30:00Z');
33+
34+
function makeFile(name: string, overrides: Partial<TreeNode> = {}): TreeNode {
35+
return {
36+
name,
37+
path: name,
38+
isDirectory: false,
39+
mode: 0o644,
40+
mtime: MTIME,
41+
size: 1024,
42+
children: [],
43+
...overrides,
44+
};
45+
}
46+
47+
function makeDir(
48+
name: string,
49+
children: TreeNode[],
50+
overrides: Partial<TreeNode> = {},
51+
): TreeNode {
52+
return {
53+
name,
54+
path: name,
55+
isDirectory: true,
56+
mode: 0o755,
57+
mtime: MTIME,
58+
size: 4096,
59+
children,
60+
...overrides,
61+
};
62+
}
63+
64+
describe('formatAsJson', () => {
65+
test('formats a file node', () => {
66+
const node = makeFile('hello.txt');
67+
expect(formatAsJson(node, opts())).toEqual({
68+
type: 'file',
69+
name: 'hello.txt',
70+
});
71+
});
72+
73+
test('formats a directory with children', () => {
74+
const node = makeDir('src', [
75+
makeFile('a.ts'),
76+
makeFile('b.ts'),
77+
]);
78+
expect(formatAsJson(node, opts())).toEqual({
79+
type: 'directory',
80+
name: 'src',
81+
contents: [
82+
{ type: 'file', name: 'a.ts' },
83+
{ type: 'file', name: 'b.ts' },
84+
],
85+
});
86+
});
87+
88+
test('formats empty directory', () => {
89+
const node = makeDir('empty', []);
90+
expect(formatAsJson(node, opts())).toEqual({
91+
type: 'directory',
92+
name: 'empty',
93+
contents: [],
94+
});
95+
});
96+
97+
test('includes size when sizes option is enabled', () => {
98+
const node = makeFile('a.ts', { size: 2048 });
99+
const result = formatAsJson(node, opts({ sizes: true }));
100+
expect(result.size).toBe(2048);
101+
});
102+
103+
test('includes mode when permissions option is enabled', () => {
104+
const node = makeFile('a.ts', { mode: 0o644 });
105+
const result = formatAsJson(node, opts({ permissions: true }));
106+
expect(result.mode).toBe('[-rw-r--r--]');
107+
});
108+
109+
test('includes directory mode when permissions option is enabled', () => {
110+
const node = makeDir('src', []);
111+
const result = formatAsJson(node, opts({ permissions: true }));
112+
expect(result.mode).toBe('[drwxr-xr-x]');
113+
});
114+
115+
test('includes time when date option is enabled', () => {
116+
const node = makeFile('a.ts');
117+
const result = formatAsJson(node, opts({ date: true }));
118+
expect(result.time).toBeDefined();
119+
expect(typeof result.time).toBe('string');
120+
});
121+
122+
test('does not include optional fields by default', () => {
123+
const node = makeFile('a.ts');
124+
const result = formatAsJson(node, opts());
125+
expect(result.size).toBeUndefined();
126+
expect(result.mode).toBeUndefined();
127+
expect(result.time).toBeUndefined();
128+
});
129+
130+
test('file nodes do not have contents', () => {
131+
const node = makeFile('a.ts');
132+
const result = formatAsJson(node, opts());
133+
expect(result.contents).toBeUndefined();
134+
});
135+
136+
test('nested directories', () => {
137+
const node = makeDir('root', [
138+
makeDir('sub', [makeFile('file.txt')]),
139+
]);
140+
const result = formatAsJson(node, opts());
141+
expect(result.contents![0].contents![0]).toEqual({
142+
type: 'file',
143+
name: 'file.txt',
144+
});
145+
});
146+
});

‎src/formats/json.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { RequiredOptions, TreeNode } from '../index';
2+
import { formatPermissions } from '../utils/format-permissions';
3+
4+
export interface JsonNode {
5+
type: 'directory' | 'file';
6+
name: string;
7+
size?: number;
8+
mode?: string;
9+
time?: string;
10+
contents?: JsonNode[];
11+
}
12+
13+
export function formatAsJson(
14+
node: TreeNode,
15+
options: RequiredOptions,
16+
): JsonNode {
17+
const jsonNode: JsonNode = {
18+
type: node.isDirectory ? 'directory' : 'file',
19+
name: node.name,
20+
};
21+
22+
if (options.sizes) {
23+
jsonNode.size = node.size;
24+
}
25+
26+
if (options.permissions) {
27+
jsonNode.mode = formatPermissions(node.mode, node.isDirectory);
28+
}
29+
30+
if (options.date) {
31+
jsonNode.time = `${node.mtime.toLocaleDateString('en-CA')} ${node.mtime.toLocaleTimeString('en-GB')}`;
32+
}
33+
34+
if (node.isDirectory) {
35+
jsonNode.contents = node.children.map((child) =>
36+
formatAsJson(child, options),
37+
);
38+
}
39+
40+
return jsonNode;
41+
}

‎src/formats/text.test.ts‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import type { RequiredOptions, TreeNode } from '../index';
4+
import { formatAsText } from './text';
5+
6+
const DEFAULT_OPTIONS: RequiredOptions = {
7+
allFiles: false,
8+
date: false,
9+
dirsFirst: false,
10+
dirsOnly: false,
11+
filesFirst: false,
12+
fullPath: false,
13+
noIndent: false,
14+
permissions: false,
15+
json: false,
16+
prune: false,
17+
quote: false,
18+
sizes: false,
19+
exclude: [],
20+
maxDepth: Number.POSITIVE_INFINITY,
21+
reverse: false,
22+
sortBy: 'name',
23+
trailingSlash: false,
24+
lineAscii: false,
25+
unsorted: false,
26+
};
27+
28+
function opts(overrides: Partial<RequiredOptions> = {}): RequiredOptions {
29+
return { ...DEFAULT_OPTIONS, ...overrides };
30+
}
31+
32+
const MTIME = new Date('2025-01-15T10:30:00Z');
33+
34+
function makeFile(name: string, overrides: Partial<TreeNode> = {}): TreeNode {
35+
return {
36+
name,
37+
path: name,
38+
isDirectory: false,
39+
mode: 0o644,
40+
mtime: MTIME,
41+
size: 1024,
42+
children: [],
43+
...overrides,
44+
};
45+
}
46+
47+
function makeDir(
48+
name: string,
49+
children: TreeNode[],
50+
overrides: Partial<TreeNode> = {},
51+
): TreeNode {
52+
return {
53+
name,
54+
path: name,
55+
isDirectory: true,
56+
mode: 0o755,
57+
mtime: MTIME,
58+
size: 4096,
59+
children,
60+
...overrides,
61+
};
62+
}
63+
64+
describe('formatAsText', () => {
65+
test('single file node', () => {
66+
const node = makeFile('hello.txt');
67+
expect(formatAsText(node, opts(), '.')).toEqual(['hello.txt']);
68+
});
69+
70+
test('directory with children', () => {
71+
const node = makeDir('src', [
72+
makeFile('a.ts', { path: 'src/a.ts' }),
73+
makeFile('b.ts', { path: 'src/b.ts' }),
74+
]);
75+
expect(formatAsText(node, opts(), '.')).toEqual([
76+
'src',
77+
'├── a.ts',
78+
'└── b.ts',
79+
]);
80+
});
81+
82+
test('nested directories', () => {
83+
const node = makeDir('root', [
84+
makeDir('sub', [makeFile('file.txt', { path: 'root/sub/file.txt' })], {
85+
path: 'root/sub',
86+
}),
87+
]);
88+
expect(formatAsText(node, opts(), '.')).toEqual([
89+
'root',
90+
'└── sub',
91+
' └── file.txt',
92+
]);
93+
});
94+
95+
test('lineAscii uses ASCII symbols', () => {
96+
const node = makeDir('src', [
97+
makeFile('a.ts', { path: 'src/a.ts' }),
98+
makeFile('b.ts', { path: 'src/b.ts' }),
99+
]);
100+
expect(formatAsText(node, opts({ lineAscii: true }), '.')).toEqual([
101+
'src',
102+
'|-- a.ts',
103+
'`-- b.ts',
104+
]);
105+
});
106+
107+
test('noIndent omits tree graphics', () => {
108+
const node = makeDir('src', [
109+
makeFile('a.ts', { path: 'src/a.ts' }),
110+
makeFile('b.ts', { path: 'src/b.ts' }),
111+
]);
112+
expect(formatAsText(node, opts({ noIndent: true }), '.')).toEqual([
113+
'src',
114+
'a.ts',
115+
'b.ts',
116+
]);
117+
});
118+
119+
test('trailingSlash appends / to directories', () => {
120+
const node = makeDir('src', [
121+
makeDir('lib', [], { path: 'src/lib' }),
122+
makeFile('index.ts', { path: 'src/index.ts' }),
123+
]);
124+
expect(formatAsText(node, opts({ trailingSlash: true }), '.')).toEqual([
125+
'src/',
126+
'├── lib/',
127+
'└── index.ts',
128+
]);
129+
});
130+
131+
test('quote wraps names in double quotes', () => {
132+
const node = makeDir('src', [makeFile('a.ts', { path: 'src/a.ts' })]);
133+
expect(formatAsText(node, opts({ quote: true }), '.')).toEqual([
134+
'"src"',
135+
'└── "a.ts"',
136+
]);
137+
});
138+
139+
test('permissions shows mode string', () => {
140+
const node = makeDir('src', [
141+
makeFile('a.ts', { path: 'src/a.ts', mode: 0o644 }),
142+
]);
143+
const lines = formatAsText(node, opts({ permissions: true }), '.');
144+
expect(lines[0]).toBe('[drwxr-xr-x] src');
145+
expect(lines[1]).toBe('└── [-rw-r--r--] a.ts');
146+
});
147+
148+
test('sizes shows file size', () => {
149+
const node = makeFile('a.ts', { size: 2048 });
150+
const lines = formatAsText(node, opts({ sizes: true }), '.');
151+
expect(lines[0]).toContain('2.05kB');
152+
});
153+
154+
test('fullPath shows relative path', () => {
155+
const node = makeDir('src', [
156+
makeFile('a.ts', { path: 'src/a.ts' }),
157+
]);
158+
const lines = formatAsText(node, opts({ fullPath: true }), 'src');
159+
expect(lines[1]).toBe('└── src/a.ts');
160+
});
161+
162+
test('date shows modification time', () => {
163+
const node = makeFile('a.ts');
164+
const lines = formatAsText(node, opts({ date: true }), '.');
165+
expect(lines[0]).toMatch(/^\[.+\] a\.ts$/);
166+
});
167+
168+
test('vertical line for non-last siblings', () => {
169+
const node = makeDir('root', [
170+
makeDir(
171+
'dir1',
172+
[makeFile('file.txt', { path: 'root/dir1/file.txt' })],
173+
{ path: 'root/dir1' },
174+
),
175+
makeFile('other.txt', { path: 'root/other.txt' }),
176+
]);
177+
const lines = formatAsText(node, opts(), '.');
178+
expect(lines).toEqual([
179+
'root',
180+
'├── dir1',
181+
'│ └── file.txt',
182+
'└── other.txt',
183+
]);
184+
});
185+
});

0 commit comments

Comments
 (0)