1import type { TurnDiffFileChange } from "../types";
2
3export interface TurnDiffStat {
4 additions: number;
5 deletions: number;
6}
7
8export interface TurnDiffTreeDirectoryNode {
9 kind: "directory";
10 name: string;
11 path: string;
12 stat: TurnDiffStat;
13 children: TurnDiffTreeNode[];
14}
15
16export interface TurnDiffTreeFileNode {
17 kind: "file";
18 name: string;
19 path: string;
20 stat: TurnDiffStat | null;
21}
22
23export type TurnDiffTreeNode = TurnDiffTreeDirectoryNode | TurnDiffTreeFileNode;
24
25interface MutableDirectoryNode {
26 name: string;
27 path: string;
28 stat: TurnDiffStat;
29 directories: Map<string, MutableDirectoryNode>;
30 files: TurnDiffTreeFileNode[];
31}
32
33const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" };
34
35function normalizePathSegments(pathValue: string): string[] {
36 return pathValue
37 .replaceAll("\\", "/")
38 .split("/")
39 .filter((segment) => segment.length > 0);
40}
41
42function compareByName(a: { name: string }, b: { name: string }): number {
43 return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS);
44}
45
46function readStat(file: TurnDiffFileChange): TurnDiffStat | null {
47 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {
48 return null;
49 }
50 return {
51 additions: file.additions,
52 deletions: file.deletions,
53 };
54}
55
56function compactDirectoryNode(node: TurnDiffTreeDirectoryNode): TurnDiffTreeDirectoryNode {
57 const compactedChildren = node.children.map((child) =>
58 child.kind === "directory" ? compactDirectoryNode(child) : child,
59 );
60
61 let compactedNode: TurnDiffTreeDirectoryNode = {
62 ...node,
63 children: compactedChildren,
64 };
65
66 while (compactedNode.children.length === 1 && compactedNode.children[0]?.kind === "directory") {
67 const onlyChild = compactedNode.children[0];
68 compactedNode = {
69 kind: "directory",
70 name: `${compactedNode.name}/${onlyChild.name}`,
71 path: onlyChild.path,
72 stat: onlyChild.stat,
73 children: onlyChild.children,
74 };
75 }
76
77 return compactedNode;
78}
79
80function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
81 const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values())
82 .toSorted(compareByName)
83 .map<TurnDiffTreeDirectoryNode>((subdirectory) => ({
84 kind: "directory",
85 name: subdirectory.name,
86 path: subdirectory.path,
87 stat: {
88 additions: subdirectory.stat.additions,
89 deletions: subdirectory.stat.deletions,
90 },
91 children: toTreeNodes(subdirectory),
92 }))
93 .map((subdirectory) => compactDirectoryNode(subdirectory));
94
95 const files = directory.files.toSorted(compareByName);
96 return [...subdirectories, ...files];
97}
98
99export function summarizeTurnDiffStats(files: ReadonlyArray<TurnDiffFileChange>): TurnDiffStat {
100 return files.reduce(
101 (acc, file) => {
102 const stat = readStat(file);
103 if (!stat) return acc;
104 return {
105 additions: acc.additions + stat.additions,
106 deletions: acc.deletions + stat.deletions,
107 };
108 },
109 { additions: 0, deletions: 0 },
110 );
111}
112
113export function buildTurnDiffTree(files: ReadonlyArray<TurnDiffFileChange>): TurnDiffTreeNode[] {
114 const root: MutableDirectoryNode = {
115 name: "",
116 path: "",
117 stat: { additions: 0, deletions: 0 },
118 directories: new Map(),
119 files: [],
120 };
121
122 for (const file of files) {
123 const segments = normalizePathSegments(file.path);
124 if (segments.length === 0) {
125 continue;
126 }
127
128 const filePath = segments.join("/");
129 const fileName = segments.at(-1);
130 if (!fileName) {
131 continue;
132 }
133 const stat = readStat(file);
134 const ancestors: MutableDirectoryNode[] = [root];
135 let currentDirectory = root;
136
137 for (const segment of segments.slice(0, -1)) {
138 const nextPath = currentDirectory.path ? `${currentDirectory.path}/${segment}` : segment;
139 const existing = currentDirectory.directories.get(segment);
140 if (existing) {
141 currentDirectory = existing;
142 } else {
143 const created: MutableDirectoryNode = {
144 name: segment,
145 path: nextPath,
146 stat: { additions: 0, deletions: 0 },
147 directories: new Map(),
148 files: [],
149 };
150 currentDirectory.directories.set(segment, created);
151 currentDirectory = created;
152 }
153 ancestors.push(currentDirectory);
154 }
155
156 currentDirectory.files.push({
157 kind: "file",
158 name: fileName,
159 path: filePath,
160 stat,
161 });
162
163 if (stat) {
164 for (const ancestor of ancestors) {
165 ancestor.stat.additions += stat.additions;
166 ancestor.stat.deletions += stat.deletions;
167 }
168 }
169 }
170
171 return toTreeNodes(root);
172}