1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9
10import { writeFileStringAtomically } from "./atomicWrite.ts";
11
12it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => {
13 it.effect("keeps a symlinked file linked and rewrites its destination", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
18 const destination = path.join(root, "dotfiles", "settings.json");
19 const link = path.join(root, "home", "settings.json");
20 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
21 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
22 yield* fs.writeFileString(destination, "before");
23 yield* fs.symlink(destination, link);
24
25 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
26
27 assert.strictEqual(yield* fs.readLink(link), destination);
28 assert.strictEqual(yield* fs.readFileString(destination), "after");
29 }),
30 );
31
32 it.effect("keeps a dangling symlink linked and creates its destination", () =>
33 Effect.gen(function* () {
34 const fs = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
37 const destination = path.join(root, "dotfiles", "settings.json");
38 const link = path.join(root, "home", "settings.json");
39 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
40 yield* fs.symlink(destination, link);
41
42 yield* writeFileStringAtomically({ filePath: link, contents: "fresh" });
43
44 assert.strictEqual(yield* fs.readLink(link), destination);
45 assert.strictEqual(yield* fs.readFileString(destination), "fresh");
46 }),
47 );
48
49 it.effect("fails on a symlink cycle without replacing either link", () =>
50 Effect.gen(function* () {
51 const fs = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
54 const first = path.join(root, "first.json");
55 const second = path.join(root, "second.json");
56 yield* fs.symlink(second, first);
57 yield* fs.symlink(first, second);
58
59 const result = yield* Effect.exit(
60 writeFileStringAtomically({ filePath: first, contents: "after" }),
61 );
62
63 assert.isTrue(Exit.isFailure(result));
64 assert.strictEqual(yield* fs.readLink(first), second);
65 assert.strictEqual(yield* fs.readLink(second), first);
66 }),
67 );
68
69 it.effect("resolves a relative link through a symlinked parent directory", () =>
70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
74 const destination = path.join(root, "dotfiles", "config", "settings.json");
75 const linkedState = path.join(root, "dotfiles", "state");
76 const home = path.join(root, "home");
77 const link = path.join(home, "state", "settings.json");
78 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
79 yield* fs.makeDirectory(linkedState, { recursive: true });
80 yield* fs.makeDirectory(home, { recursive: true });
81 yield* fs.symlink(linkedState, path.join(home, "state"));
82 yield* fs.writeFileString(destination, "before");
83 yield* fs.symlink("../config/settings.json", link);
84
85 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
86
87 assert.strictEqual(yield* fs.readLink(link), "../config/settings.json");
88 assert.strictEqual(yield* fs.readFileString(destination), "after");
89 }),
90 );
91
92 it.effect("creates a missing file and its directory", () =>
93 Effect.gen(function* () {
94 const fs = yield* FileSystem.FileSystem;
95 const path = yield* Path.Path;
96 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
97 const filePath = path.join(root, "nested", "settings.json");
98
99 yield* writeFileStringAtomically({ filePath, contents: "fresh" });
100
101 assert.strictEqual(yield* fs.readFileString(filePath), "fresh");
102 }),
103 );
104});
105
106it.effect("surfaces an unreadable link instead of writing over it", () =>
107 Effect.gen(function* () {
108 const readLinkFailure = PlatformError.systemError({
109 _tag: "Unknown",
110 module: "FileSystem",
111 method: "readLink",
112 pathOrDescriptor: "/home/settings.json",
113 });
114
115 const result = yield* Effect.exit(
116 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }),
117 );
118
119 assert.deepStrictEqual(result, Exit.fail(readLinkFailure));
120 }).pipe(
121 Effect.provide(
122 Layer.mergeAll(
123 Path.layer,
124 FileSystem.layerNoop({
125 readLink: () =>
126 Effect.fail(
127 PlatformError.systemError({
128 _tag: "Unknown",
129 module: "FileSystem",
130 method: "readLink",
131 pathOrDescriptor: "/home/settings.json",
132 }),
133 ),
134 rename: () => Effect.die("an unreadable link must not be replaced"),
135 }),
136 ),
137 ),
138 ),
139);
140
141it.effect("succeeds when the write lands but its temp directory cannot be removed", () =>
142 Effect.gen(function* () {
143 const renamed: Array<string> = [];
144 const removed: Array<string> = [];
145 const fileSystem = FileSystem.layerNoop({
146 // The target does not exist yet, so it is written in place.
147 readLink: (path) =>
148 Effect.fail(
149 PlatformError.systemError({
150 _tag: "NotFound",
151 module: "FileSystem",
152 method: "readLink",
153 pathOrDescriptor: path,
154 }),
155 ),
156 makeDirectory: () => Effect.void,
157 makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"),
158 writeFileString: () => Effect.void,
159 rename: (_from, to) => Effect.sync(() => void renamed.push(to)),
160 remove: (path) =>
161 Effect.sync(() => void removed.push(path)).pipe(
162 Effect.andThen(
163 Effect.fail(
164 PlatformError.systemError({
165 _tag: "PermissionDenied",
166 module: "FileSystem",
167 method: "remove",
168 pathOrDescriptor: path,
169 }),
170 ),
171 ),
172 ),
173 });
174
175 const result = yield* Effect.exit(
176 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe(
177 Effect.provide(Layer.mergeAll(Path.layer, fileSystem)),
178 ),
179 );
180
181 assert.deepStrictEqual(result, Exit.void);
182 assert.deepStrictEqual(renamed, ["/home/settings.json"]);
183 assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]);
184 }),
185);