import { describe, it } from "node:test"; import type { Database, DocumentRecord, RelativePath } from "../persistence/database"; import { FileOperations } from "./file-operations"; import { Logger } from "../tracing/logger"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; class MockDatabase implements Partial { public getLatestDocumentByRelativePath( _find: RelativePath ): DocumentRecord | undefined { // no-op return undefined; } public move( _oldRelativePath: RelativePath, _newRelativePath: RelativePath ): void { // no-op } } class FakeFileSystemOperations implements FileSystemOperations { public readonly names = new Set(); public async listFilesRecursively( _root: RelativePath | undefined ): Promise { return ["file.md"]; } public async read(_path: RelativePath): Promise { throw new Error("Method not implemented."); } public async write( path: RelativePath, _content: Uint8Array ): Promise { this.names.add(path); } public async atomicUpdateText( _path: RelativePath, _updater: (current: TextWithCursors) => TextWithCursors ): Promise { throw new Error("Method not implemented."); } public async getFileSize(_path: RelativePath): Promise { throw new Error("Method not implemented."); } public async getModificationTime(_path: RelativePath): Promise { throw new Error("Method not implemented."); } public async exists(path: RelativePath): Promise { return this.names.has(path); } public async createDirectory(_path: RelativePath): Promise { // this is called but irrelevant for this mock } public async delete(_path: RelativePath): Promise { throw new Error("Method not implemented."); } public async rename( oldPath: RelativePath, newPath: RelativePath ): Promise { this.names.delete(oldPath); this.names.add(newPath); } } describe("File operations", () => { it("should deconflict renames", async () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion fileSystemOperations ); await fileOperations.create("a", new Uint8Array()); assertSetContainsExactly(fileSystemOperations.names, "a"); await fileOperations.move("a", "b"); assertSetContainsExactly(fileSystemOperations.names, "b"); await fileOperations.create("c", new Uint8Array()); assertSetContainsExactly(fileSystemOperations.names, "b", "c"); await fileOperations.move("c", "b"); assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); await fileOperations.create("c", new Uint8Array()); await fileOperations.move("c", "b"); assertSetContainsExactly( fileSystemOperations.names, "b", "b (1)", "b (2)" ); }); it("should deconflict renames with file extension", async () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion fileSystemOperations ); await fileOperations.create("b.md", new Uint8Array()); await fileOperations.create("c.md", new Uint8Array()); await fileOperations.move("c.md", "b.md"); assertSetContainsExactly( fileSystemOperations.names, "b.md", "b (1).md" ); await fileOperations.create("d.md", new Uint8Array()); await fileOperations.move("d.md", "b.md"); assertSetContainsExactly( fileSystemOperations.names, "b.md", "b (1).md", "b (2).md" ); await fileOperations.create("file-23.md", new Uint8Array()); await fileOperations.create("file-23 (1).md", new Uint8Array()); await fileOperations.move("file-23.md", "file-23 (1).md"); assertSetContainsExactly( fileSystemOperations.names, "b.md", "b (1).md", "b (2).md", "file-23 (1).md", "file-23 (2).md" ); }); it("should deconflict renames with paths", async () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion fileSystemOperations ); await fileOperations.create("a/b.c/d", new Uint8Array()); await fileOperations.create("a/b.c/e", new Uint8Array()); await fileOperations.move("a/b.c/d", "a/b.c/e"); assertSetContainsExactly( fileSystemOperations.names, "a/b.c/e", "a/b.c/e (1)" ); }); it("should continue deconfliction from existing number in filename", async () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion fileSystemOperations ); await fileOperations.create("document (5).md", new Uint8Array()); await fileOperations.create("other.md", new Uint8Array()); await fileOperations.move("other.md", "document (5).md"); assertSetContainsExactly( fileSystemOperations.names, "document (5).md", "document (6).md" ); await fileOperations.create("another.md", new Uint8Array()); await fileOperations.move("another.md", "document (5).md"); assertSetContainsExactly( fileSystemOperations.names, "document (5).md", "document (6).md", "document (7).md" ); }); it("should handle dotfiles correctly", async () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion fileSystemOperations ); await fileOperations.create(".gitignore", new Uint8Array()); await fileOperations.create("temp", new Uint8Array()); await fileOperations.move("temp", ".gitignore"); assertSetContainsExactly( fileSystemOperations.names, ".gitignore", ".gitignore (1)" ); await fileOperations.create(".config.json", new Uint8Array()); await fileOperations.create("temp2", new Uint8Array()); await fileOperations.move("temp2", ".config.json"); assertSetContainsExactly( fileSystemOperations.names, ".gitignore", ".gitignore (1)", ".config.json", ".config (1).json" ); }); });