Skip to content

Commit 211be5b

Browse files
committed
feat(utils): convert files array to tree structure
1 parent ab462d4 commit 211be5b

File tree

4 files changed

+189
-0
lines changed

4 files changed

+189
-0
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type {
2+
CoverageTree,
3+
CoverageTreeMissingLOC,
4+
CoverageTreeNode,
5+
} from '@code-pushup/models';
6+
import { splitFilePath } from './file-system.js';
7+
import { formatGitPath } from './git/git.js';
8+
9+
type FileCoverage = {
10+
path: string;
11+
total: number;
12+
hits: number;
13+
missing: CoverageTreeMissingLOC[];
14+
};
15+
16+
// TODO: calculate folder coverage
17+
const COVERAGE_PLACEHOLDER = -1;
18+
19+
export function filesCoverageToTree(
20+
files: FileCoverage[],
21+
gitRoot: string,
22+
title?: string,
23+
): CoverageTree {
24+
const normalizedFiles = files.map(file => ({
25+
...file,
26+
path: formatGitPath(file.path, gitRoot),
27+
}));
28+
29+
const root = normalizedFiles.reduce<CoverageTreeNode>(
30+
(acc: CoverageTreeNode, { path: filePath, ...coverage }) => {
31+
const { folders, file } = splitFilePath(filePath);
32+
return addNode(acc, folders, file, coverage);
33+
},
34+
{ name: '.', values: { coverage: COVERAGE_PLACEHOLDER } },
35+
);
36+
37+
return {
38+
type: 'coverage',
39+
...(title && { title }),
40+
root,
41+
};
42+
}
43+
44+
function addNode(
45+
root: CoverageTreeNode,
46+
folders: string[],
47+
file: string,
48+
coverage: Omit<FileCoverage, 'path'>,
49+
): CoverageTreeNode {
50+
const folder = folders[0];
51+
52+
if (folder) {
53+
if (root.children?.some(({ name }) => name === folder)) {
54+
return {
55+
...root,
56+
children: root.children.map(node =>
57+
node.name === folder
58+
? addNode(node, folders.slice(1), file, coverage)
59+
: node,
60+
),
61+
};
62+
}
63+
return {
64+
...root,
65+
children: [
66+
...(root.children ?? []),
67+
addNode(
68+
{ name: folder, values: { coverage: COVERAGE_PLACEHOLDER } },
69+
folders.slice(1),
70+
file,
71+
coverage,
72+
),
73+
],
74+
};
75+
}
76+
77+
return {
78+
...root,
79+
children: [
80+
...(root.children ?? []),
81+
{
82+
name: file,
83+
values: {
84+
coverage: calculateCoverage(coverage),
85+
missing: coverage.missing,
86+
},
87+
},
88+
],
89+
};
90+
}
91+
92+
function calculateCoverage({
93+
hits,
94+
total,
95+
}: Pick<FileCoverage, 'hits' | 'total'>): number {
96+
if (total === 0) {
97+
return 1;
98+
}
99+
return hits / total;
100+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import path from 'node:path';
2+
import { filesCoverageToTree } from './coverage-tree.js';
3+
4+
describe('filesCoverageToTree', () => {
5+
it('should convert list of files to folder structure', () => {
6+
const mockCoverage = { hits: 0, total: 0, missing: [] };
7+
const files = [
8+
{
9+
...mockCoverage,
10+
path: path.join(process.cwd(), 'src', 'components', 'CreateTodo.jsx'),
11+
},
12+
{
13+
...mockCoverage,
14+
path: path.join(process.cwd(), 'src', 'components', 'TodoFilter.jsx'),
15+
},
16+
{
17+
...mockCoverage,
18+
path: path.join(process.cwd(), 'src', 'components', 'TodoList.jsx'),
19+
},
20+
{
21+
...mockCoverage,
22+
path: path.join(process.cwd(), 'src', 'hooks', 'useTodos.js'),
23+
},
24+
{
25+
...mockCoverage,
26+
path: path.join(process.cwd(), 'src', 'App.jsx'),
27+
},
28+
];
29+
30+
expect(filesCoverageToTree(files, process.cwd())).toEqual(
31+
expect.objectContaining({
32+
root: expect.objectContaining({
33+
name: '.',
34+
children: [
35+
expect.objectContaining({
36+
name: 'src',
37+
children: [
38+
expect.objectContaining({
39+
name: 'components',
40+
children: [
41+
expect.objectContaining({ name: 'CreateTodo.jsx' }),
42+
expect.objectContaining({ name: 'TodoFilter.jsx' }),
43+
expect.objectContaining({ name: 'TodoList.jsx' }),
44+
],
45+
}),
46+
expect.objectContaining({
47+
name: 'hooks',
48+
children: [expect.objectContaining({ name: 'useTodos.js' })],
49+
}),
50+
expect.objectContaining({ name: 'App.jsx' }),
51+
],
52+
}),
53+
],
54+
}),
55+
}),
56+
);
57+
});
58+
});

packages/utils/src/lib/file-system.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,24 @@ export function filePathToCliArg(filePath: string): string {
159159
export function projectToFilename(project: string): string {
160160
return project.replace(/[/\\\s]+/g, '-').replace(/@/g, '');
161161
}
162+
163+
type SplitFilePath = {
164+
folders: string[];
165+
file: string;
166+
};
167+
168+
export function splitFilePath(filePath: string): SplitFilePath {
169+
const file = path.basename(filePath);
170+
const folders: string[] = [];
171+
// eslint-disable-next-line functional/no-loop-statements
172+
for (
173+
// eslint-disable-next-line functional/no-let
174+
let dirPath = path.dirname(filePath);
175+
path.dirname(dirPath) !== dirPath;
176+
dirPath = path.dirname(dirPath)
177+
) {
178+
// eslint-disable-next-line functional/immutable-data
179+
folders.unshift(path.basename(dirPath));
180+
}
181+
return { folders, file };
182+
}

packages/utils/src/lib/file-system.unit.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
findNearestFile,
1313
logMultipleFileResults,
1414
projectToFilename,
15+
splitFilePath,
1516
} from './file-system.js';
1617
import * as logResults from './log-results.js';
1718

@@ -263,3 +264,12 @@ describe('projectToFilename', () => {
263264
expect(projectToFilename(project)).toBe(file);
264265
});
265266
});
267+
268+
describe('splitFilePath', () => {
269+
it('should extract folders from file path', () => {
270+
expect(splitFilePath(path.join('src', 'app', 'app.component.ts'))).toEqual({
271+
folders: ['src', 'app'],
272+
file: 'app.component.ts',
273+
});
274+
});
275+
});

0 commit comments

Comments
 (0)