branch:
memory.test.ts
26952 bytesRaw
import { InMemoryFs } from "../fs/in-memory-fs";
import { describe, expect, it } from "vitest";
import { createMemoryStateBackend } from "../memory";
import { StateBatchOperationError } from "../index";

describe("MemoryStateBackend", () => {
  it("reads, writes, and appends text content", async () => {
    const backend = createMemoryStateBackend();

    await backend.writeFile("/notes/todo.txt", "hello");
    await backend.appendFile("/notes/todo.txt", "\nworld");

    await expect(backend.readFile("/notes/todo.txt")).resolves.toBe(
      "hello\nworld"
    );
  });

  it("reads and writes JSON content", async () => {
    const backend = createMemoryStateBackend();

    await backend.writeJson("/config.json", {
      name: "demo",
      flags: ["a", "b"]
    });

    await expect(backend.readJson("/config.json")).resolves.toEqual({
      name: "demo",
      flags: ["a", "b"]
    });
    await expect(backend.readFile("/config.json")).resolves.toBe(
      '{\n  "name": "demo",\n  "flags": [\n    "a",\n    "b"\n  ]\n}\n'
    );
  });

  it("throws a helpful error for invalid JSON", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/broken.json": "{ nope"
      }
    });

    await expect(backend.readJson("/broken.json")).rejects.toThrow(
      "Invalid JSON in /broken.json"
    );
  });

  it("supports host-side globbing and unified diffs", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": "export const a = 1;\n",
        "/src/b.ts": "export const b = 2;\n"
      }
    });

    await expect(backend.glob("/src/*.ts")).resolves.toEqual([
      "/src/a.ts",
      "/src/b.ts"
    ]);

    const diff = await backend.diffContent(
      "/src/a.ts",
      "export const a = 3;\n"
    );
    expect(diff).toContain("--- /src/a.ts");
    expect(diff).toContain("+++ /src/a.ts");
    expect(diff).toContain("-export const a = 1;");
    expect(diff).toContain("+export const a = 3;");
  });

  it("supports text search with plain text, regex, and word boundaries", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/notes.txt": "foo food\nFoo foo42\nfoo\n"
      }
    });

    await expect(backend.searchText("/notes.txt", "foo")).resolves.toEqual([
      { line: 1, column: 1, match: "foo", lineText: "foo food" },
      { line: 1, column: 5, match: "foo", lineText: "foo food" },
      { line: 2, column: 5, match: "foo", lineText: "Foo foo42" },
      { line: 3, column: 1, match: "foo", lineText: "foo" }
    ]);

    await expect(
      backend.searchText("/notes.txt", "foo", { caseSensitive: false })
    ).resolves.toEqual([
      { line: 1, column: 1, match: "foo", lineText: "foo food" },
      { line: 1, column: 5, match: "foo", lineText: "foo food" },
      { line: 2, column: 1, match: "Foo", lineText: "Foo foo42" },
      { line: 2, column: 5, match: "foo", lineText: "Foo foo42" },
      { line: 3, column: 1, match: "foo", lineText: "foo" }
    ]);

    await expect(
      backend.searchText("/notes.txt", "foo", { wholeWord: true })
    ).resolves.toEqual([
      { line: 1, column: 1, match: "foo", lineText: "foo food" },
      { line: 3, column: 1, match: "foo", lineText: "foo" }
    ]);

    await expect(
      backend.searchText("/notes.txt", "foo\\d+", { regex: true })
    ).resolves.toEqual([
      { line: 2, column: 5, match: "foo42", lineText: "Foo foo42" }
    ]);
  });

  it("supports richer search context and max match limits", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/search.txt": "zero\none foo\ntwo\nthree foo\nfour\n"
      }
    });

    await expect(
      backend.searchText("/search.txt", "foo", {
        contextBefore: 1,
        contextAfter: 1,
        maxMatches: 1
      })
    ).resolves.toEqual([
      {
        line: 2,
        column: 5,
        match: "foo",
        lineText: "one foo",
        beforeLines: ["zero"],
        afterLines: ["two"]
      }
    ]);
  });

  it("supports structured file discovery via find", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": "a",
        "/src/nested/b.ts": "bb",
        "/src/nested/c.txt": "ccc",
        "/empty.txt": ""
      }
    });

    await expect(
      backend.find("/src", { type: "file", pathPattern: "/src/**/*.ts" })
    ).resolves.toEqual([
      {
        path: "/src/a.ts",
        name: "a.ts",
        type: "file",
        depth: 1,
        size: 1,
        mtime: expect.any(Date)
      },
      {
        path: "/src/nested/b.ts",
        name: "b.ts",
        type: "file",
        depth: 2,
        size: 2,
        mtime: expect.any(Date)
      }
    ]);

    await expect(
      backend.find("/", { empty: true, type: "file" })
    ).resolves.toEqual([
      {
        path: "/empty.txt",
        name: "empty.txt",
        type: "file",
        depth: 1,
        size: 0,
        mtime: expect.any(Date)
      }
    ]);
  });

  it("supports JSON query and update operations", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/config.json": '{ "app": { "name": "demo", "flags": ["a", "b"] } }\n'
      }
    });

    await expect(
      backend.queryJson("/config.json", ".app.flags[1]")
    ).resolves.toBe("b");

    const updated = await backend.updateJson("/config.json", [
      { op: "set", path: ".app.name", value: "demo-2" },
      { op: "set", path: ".app.flags[2]", value: "c" },
      { op: "delete", path: ".app.flags[0]" }
    ]);

    expect(updated.operationsApplied).toBe(3);
    expect(updated.value).toEqual({
      app: { name: "demo-2", flags: ["b", "c"] }
    });
    await expect(backend.readJson("/config.json")).resolves.toEqual({
      app: { name: "demo-2", flags: ["b", "c"] }
    });
  });

  it("supports in-file replacement without touching unmatched content", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/replace.txt": "foo food foo\n"
      }
    });

    await expect(
      backend.replaceInFile("/replace.txt", "foo", "bar", {
        wholeWord: true
      })
    ).resolves.toEqual({
      replaced: 2,
      content: "bar food bar\n"
    });

    await expect(backend.readFile("/replace.txt")).resolves.toBe(
      "bar food bar\n"
    );
  });

  it("supports multi-file search across glob matches", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": 'export const alpha = "foo";\n',
        "/src/b.ts": 'export const beta = "bar";\n',
        "/src/c.ts": 'export const gamma = "foo";\n'
      }
    });

    await expect(backend.searchFiles("/src/*.ts", "foo")).resolves.toEqual([
      {
        path: "/src/a.ts",
        matches: [
          {
            line: 1,
            column: 23,
            match: "foo",
            lineText: 'export const alpha = "foo";'
          }
        ]
      },
      {
        path: "/src/c.ts",
        matches: [
          {
            line: 1,
            column: 23,
            match: "foo",
            lineText: 'export const gamma = "foo";'
          }
        ]
      }
    ]);
  });

  it("supports multi-file replacement with dry-run previews", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": 'export const alpha = "foo";\n',
        "/src/b.ts": 'export const beta = "foo";\n'
      }
    });

    const preview = await backend.replaceInFiles("/src/*.ts", "foo", "bar", {
      dryRun: true
    });

    expect(preview).toEqual({
      dryRun: true,
      files: [
        {
          path: "/src/a.ts",
          replaced: 1,
          content: 'export const alpha = "bar";\n',
          diff: '--- /src/a.ts\n+++ /src/a.ts\n@@ -1,2 +1,2 @@\n-export const alpha = "foo";\n+export const alpha = "bar";\n '
        },
        {
          path: "/src/b.ts",
          replaced: 1,
          content: 'export const beta = "bar";\n',
          diff: '--- /src/b.ts\n+++ /src/b.ts\n@@ -1,2 +1,2 @@\n-export const beta = "foo";\n+export const beta = "bar";\n '
        }
      ],
      totalFiles: 2,
      totalReplacements: 2
    });

    await expect(backend.readFile("/src/a.ts")).resolves.toBe(
      'export const alpha = "foo";\n'
    );
  });

  it("applies multi-file replacements when not in dry-run mode", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": 'export const alpha = "foo";\n',
        "/src/b.ts": 'export const beta = "foo";\n',
        "/src/c.ts": 'export const gamma = "nope";\n'
      }
    });

    const result = await backend.replaceInFiles("/src/*.ts", "foo", "bar");

    expect(result.totalFiles).toBe(2);
    expect(result.totalReplacements).toBe(2);
    await expect(backend.readFile("/src/a.ts")).resolves.toBe(
      'export const alpha = "bar";\n'
    );
    await expect(backend.readFile("/src/b.ts")).resolves.toBe(
      'export const beta = "bar";\n'
    );
    await expect(backend.readFile("/src/c.ts")).resolves.toBe(
      'export const gamma = "nope";\n'
    );
  });

  it("applies batches of edits with dry-run and no-op tracking", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": "a\n",
        "/src/b.ts": "b\n"
      }
    });

    const preview = await backend.applyEdits(
      [
        { path: "/src/a.ts", content: "aa\n" },
        { path: "/src/b.ts", content: "b\n" },
        { path: "/src/c.ts", content: "c\n" }
      ],
      { dryRun: true }
    );

    expect(preview.totalChanged).toBe(2);
    expect(preview.edits[0].changed).toBe(true);
    expect(preview.edits[1].changed).toBe(false);
    expect(preview.edits[2].changed).toBe(true);
    await expect(backend.exists("/src/c.ts")).resolves.toBe(false);

    const applied = await backend.applyEdits([
      { path: "/src/a.ts", content: "aa\n" },
      { path: "/src/b.ts", content: "b\n" },
      { path: "/src/c.ts", content: "c\n" }
    ]);

    expect(applied.totalChanged).toBe(2);
    await expect(backend.readFile("/src/a.ts")).resolves.toBe("aa\n");
    await expect(backend.readFile("/src/b.ts")).resolves.toBe("b\n");
    await expect(backend.readFile("/src/c.ts")).resolves.toBe("c\n");
  });

  it("plans structured edits by intent and applies the resulting plan", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/src/a.ts": 'export const a = "foo";\n',
        "/src/data.json": '{ "count": 1 }\n'
      }
    });

    const plan = await backend.planEdits([
      {
        kind: "replace",
        path: "/src/a.ts",
        search: "foo",
        replacement: "bar"
      },
      {
        kind: "writeJson",
        path: "/src/data.json",
        value: { count: 2 }
      },
      {
        kind: "write",
        path: "/src/new.ts",
        content: "export const created = true;\n"
      }
    ]);

    expect(plan.totalInstructions).toBe(3);
    expect(plan.totalChanged).toBe(3);
    expect(plan.edits[0]).toEqual({
      instruction: {
        kind: "replace",
        path: "/src/a.ts",
        search: "foo",
        replacement: "bar"
      },
      path: "/src/a.ts",
      changed: true,
      content: 'export const a = "bar";\n',
      diff: expect.stringContaining("--- /src/a.ts")
    });
    expect(plan.edits[1]).toEqual({
      instruction: {
        kind: "writeJson",
        path: "/src/data.json",
        value: { count: 2 }
      },
      path: "/src/data.json",
      changed: true,
      content: '{\n  "count": 2\n}\n',
      diff: expect.stringContaining("--- /src/data.json")
    });
    expect(plan.edits[2]).toEqual({
      instruction: {
        kind: "write",
        path: "/src/new.ts",
        content: "export const created = true;\n"
      },
      path: "/src/new.ts",
      changed: true,
      content: "export const created = true;\n",
      diff: expect.stringContaining("--- /src/new.ts")
    });

    await expect(
      backend.applyEditPlan(plan, { dryRun: true })
    ).resolves.toMatchObject({
      dryRun: true,
      totalChanged: 3
    });
    await expect(backend.exists("/src/new.ts")).resolves.toBe(false);

    const applied = await backend.applyEditPlan(plan);
    expect(applied.totalChanged).toBe(3);
    await expect(backend.readFile("/src/a.ts")).resolves.toBe(
      'export const a = "bar";\n'
    );
    await expect(backend.readFile("/src/data.json")).resolves.toBe(
      '{\n  "count": 2\n}\n'
    );
    await expect(backend.readFile("/src/new.ts")).resolves.toBe(
      "export const created = true;\n"
    );
  });

  it("fails planning when a replace instruction targets a missing file", async () => {
    const backend = createMemoryStateBackend();

    await expect(
      backend.planEdits([
        {
          kind: "replace",
          path: "/src/missing.ts",
          search: "foo",
          replacement: "bar"
        }
      ])
    ).rejects.toThrow("ENOENT: no such file: /src/missing.ts");
  });

  it("rolls back multi-file replacements when a later write fails", async () => {
    const backend = createMemoryStateBackend({
      fs: new FailingWriteFs(
        new InMemoryFs({
          "/src/a.ts": 'export const alpha = "foo";\n',
          "/src/b.ts": 'export const beta = "foo";\n'
        }),
        "/src/b.ts"
      )
    });

    await expect(
      backend.replaceInFiles("/src/*.ts", "foo", "bar")
    ).rejects.toMatchObject({
      name: "StateBatchOperationError",
      operation: "replaceInFiles",
      rolledBack: true
    } satisfies Partial<StateBatchOperationError>);

    await expect(backend.readFile("/src/a.ts")).resolves.toBe(
      'export const alpha = "foo";\n'
    );
    await expect(backend.readFile("/src/b.ts")).resolves.toBe(
      'export const beta = "foo";\n'
    );
  });

  it("can leave partial writes in place when rollback is disabled", async () => {
    const backend = createMemoryStateBackend({
      fs: new FailingWriteFs(
        new InMemoryFs({
          "/src/a.ts": 'export const alpha = "foo";\n',
          "/src/b.ts": 'export const beta = "foo";\n'
        }),
        "/src/b.ts"
      )
    });

    await expect(
      backend.replaceInFiles("/src/*.ts", "foo", "bar", {
        rollbackOnError: false
      })
    ).rejects.toMatchObject({
      name: "StateBatchOperationError",
      operation: "replaceInFiles",
      rolledBack: false
    } satisfies Partial<StateBatchOperationError>);

    await expect(backend.readFile("/src/a.ts")).resolves.toBe(
      'export const alpha = "bar";\n'
    );
    await expect(backend.readFile("/src/b.ts")).resolves.toBe(
      'export const beta = "foo";\n'
    );
  });

  it("rolls back applied edits when a later edit write fails", async () => {
    const backend = createMemoryStateBackend({
      fs: new FailingWriteFs(
        new InMemoryFs({
          "/src/a.ts": "a\n",
          "/src/b.ts": "b\n"
        }),
        "/src/b.ts"
      )
    });

    await expect(
      backend.applyEdits([
        { path: "/src/a.ts", content: "aa\n" },
        { path: "/src/b.ts", content: "bb\n" },
        { path: "/src/c.ts", content: "cc\n" }
      ])
    ).rejects.toMatchObject({
      name: "StateBatchOperationError",
      operation: "applyEdits",
      rolledBack: true
    } satisfies Partial<StateBatchOperationError>);

    await expect(backend.readFile("/src/a.ts")).resolves.toBe("a\n");
    await expect(backend.readFile("/src/b.ts")).resolves.toBe("b\n");
    await expect(backend.exists("/src/c.ts")).resolves.toBe(false);
  });

  it("supports recursive copy and move helpers", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/project/src/index.ts": "export const value = 1;\n"
      }
    });

    await backend.copyTree("/project", "/project-copy");
    await backend.moveTree("/project-copy", "/archive/project-copy");

    await expect(
      backend.readFile("/archive/project-copy/src/index.ts")
    ).resolves.toBe("export const value = 1;\n");
    await expect(backend.exists("/project-copy")).resolves.toBe(false);
  });

  it("supports tree walking and directory summaries", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/tree/a.txt": "a",
        "/tree/nested/b.txt": "bb",
        "/tree/nested/deeper/c.txt": "ccc"
      }
    });

    const tree = await backend.walkTree("/tree", { maxDepth: 2 });
    expect(tree).toEqual({
      path: "/tree",
      name: "tree",
      type: "directory",
      size: 0,
      children: [
        {
          path: "/tree/a.txt",
          name: "a.txt",
          type: "file",
          size: 1
        },
        {
          path: "/tree/nested",
          name: "nested",
          type: "directory",
          size: 0,
          children: [
            {
              path: "/tree/nested/b.txt",
              name: "b.txt",
              type: "file",
              size: 2
            },
            {
              path: "/tree/nested/deeper",
              name: "deeper",
              type: "directory",
              size: 0
            }
          ]
        }
      ]
    });

    await expect(backend.summarizeTree("/tree")).resolves.toEqual({
      files: 3,
      directories: 3,
      symlinks: 0,
      totalBytes: 6,
      maxDepth: 3
    });
  });

  it("supports archive creation, listing, extraction, compression, hashing, and file detection", async () => {
    const backend = createMemoryStateBackend({
      files: {
        "/archive-src/a.txt": "hello",
        "/archive-src/nested/b.txt": "world"
      }
    });

    const archive = await backend.createArchive("/bundle.tar", [
      "/archive-src"
    ]);
    expect(archive.path).toBe("/bundle.tar");
    expect(archive.entries).toEqual([
      { path: "archive-src", type: "directory", size: 0 },
      { path: "archive-src/a.txt", type: "file", size: 5 },
      { path: "archive-src/nested", type: "directory", size: 0 },
      { path: "archive-src/nested/b.txt", type: "file", size: 5 }
    ]);

    await expect(backend.listArchive("/bundle.tar")).resolves.toEqual(
      archive.entries
    );

    const extracted = await backend.extractArchive("/bundle.tar", "/restored");
    expect(extracted.destination).toBe("/restored");
    await expect(backend.readFile("/restored/archive-src/a.txt")).resolves.toBe(
      "hello"
    );
    await expect(
      backend.readFile("/restored/archive-src/nested/b.txt")
    ).resolves.toBe("world");

    const compressed = await backend.compressFile("/archive-src/a.txt");
    expect(compressed.destination).toBe("/archive-src/a.txt.gz");
    const decompressed = await backend.decompressFile(
      "/archive-src/a.txt.gz",
      "/archive-src/a-restored.txt"
    );
    expect(decompressed.destination).toBe("/archive-src/a-restored.txt");
    await expect(backend.readFile("/archive-src/a-restored.txt")).resolves.toBe(
      "hello"
    );

    await expect(
      backend.hashFile("/archive-src/a.txt", { algorithm: "sha256" })
    ).resolves.toBe(
      "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
    );

    await expect(backend.detectFile("/archive-src/a.txt")).resolves.toEqual({
      mime: "text/plain",
      extension: "txt",
      binary: false,
      description: "text/plain (txt)"
    });
  });
});

describe("InMemoryFs — symlinks", () => {
  it("stat follows symlinks, lstat does not", async () => {
    const fs = new InMemoryFs({ "/target.txt": "hello" });
    await fs.symlink("/target.txt", "/link.txt");

    const stat = await fs.stat("/link.txt");
    expect(stat.type).toBe("file");
    expect(stat.size).toBe(5);

    const lstat = await fs.lstat("/link.txt");
    expect(lstat.type).toBe("symlink");
  });

  it("readlink returns the symlink target", async () => {
    const fs = new InMemoryFs();
    await fs.writeFile("/a.txt", "a");
    await fs.symlink("/a.txt", "/link");

    await expect(fs.readlink("/link")).resolves.toBe("/a.txt");
  });

  it("readlink throws on non-symlink", async () => {
    const fs = new InMemoryFs({ "/plain.txt": "x" });
    await expect(fs.readlink("/plain.txt")).rejects.toThrow("EINVAL");
  });

  it("realpath resolves through symlinks", async () => {
    const fs = new InMemoryFs({ "/dir/file.txt": "content" });
    await fs.symlink("/dir", "/link");

    await expect(fs.realpath("/link/file.txt")).resolves.toBe("/dir/file.txt");
  });

  it("resolves intermediate symlinks when reading files", async () => {
    const fs = new InMemoryFs({ "/actual/data.txt": "payload" });
    await fs.symlink("/actual", "/shortcut");

    await expect(fs.readFile("/shortcut/data.txt")).resolves.toBe("payload");
  });

  it("resolves chained symlinks", async () => {
    const fs = new InMemoryFs({ "/root.txt": "end" });
    await fs.symlink("/root.txt", "/hop1");
    await fs.symlink("/hop1", "/hop2");
    await fs.symlink("/hop2", "/hop3");

    await expect(fs.readFile("/hop3")).resolves.toBe("end");
    await expect(fs.realpath("/hop3")).resolves.toBe("/root.txt");
  });

  it("resolves relative symlinks", async () => {
    const fs = new InMemoryFs({ "/dir/target.txt": "found" });
    await fs.symlink("target.txt", "/dir/link");

    await expect(fs.readFile("/dir/link")).resolves.toBe("found");
  });

  it("throws ELOOP on circular symlinks", async () => {
    const fs = new InMemoryFs();
    await fs.symlink("/b", "/a");
    await fs.symlink("/a", "/b");

    await expect(fs.readFile("/a")).rejects.toThrow("ELOOP");
  });

  it("cp preserves symlinks instead of following them", async () => {
    const fs = new InMemoryFs({ "/target.txt": "data" });
    await fs.symlink("/target.txt", "/link");
    await fs.cp("/link", "/copied");

    const lstat = await fs.lstat("/copied");
    expect(lstat.type).toBe("symlink");
    await expect(fs.readlink("/copied")).resolves.toBe("/target.txt");
  });

  it("mv preserves symlinks", async () => {
    const fs = new InMemoryFs({ "/target.txt": "data" });
    await fs.symlink("/target.txt", "/link");
    await fs.mv("/link", "/moved");

    await expect(fs.readlink("/moved")).resolves.toBe("/target.txt");
    await expect(fs.exists("/link")).resolves.toBe(false);
  });

  it("readdirWithFileTypes reports symlinks", async () => {
    const fs = new InMemoryFs({ "/dir/file.txt": "x" });
    await fs.symlink("/dir/file.txt", "/dir/link");

    const entries = await fs.readdirWithFileTypes("/dir");
    const linkEntry = entries.find((e) => e.name === "link");
    expect(linkEntry?.type).toBe("symlink");
  });
});

describe("InMemoryFs — hard links", () => {
  it("link creates a file sharing the source content", async () => {
    const fs = new InMemoryFs({ "/original.txt": "shared" });
    await fs.link("/original.txt", "/hardlink.txt");

    await expect(fs.readFile("/hardlink.txt")).resolves.toBe("shared");
  });

  it("link throws EEXIST when destination exists", async () => {
    const fs = new InMemoryFs({
      "/a.txt": "a",
      "/b.txt": "b"
    });
    await expect(fs.link("/a.txt", "/b.txt")).rejects.toThrow("EEXIST");
  });

  it("link throws EPERM on directories", async () => {
    const fs = new InMemoryFs({ "/dir/child.txt": "c" });
    await expect(fs.link("/dir", "/link")).rejects.toThrow("EPERM");
  });
});

describe("InMemoryFs — chmod and utimes", () => {
  it("chmod changes file mode", async () => {
    const fs = new InMemoryFs({ "/f.txt": "x" });
    await fs.chmod("/f.txt", 0o755);

    const stat = await fs.stat("/f.txt");
    expect(stat.mode).toBe(0o755);
  });

  it("utimes changes modification time", async () => {
    const fs = new InMemoryFs({ "/f.txt": "x" });
    const past = new Date("2020-01-01T00:00:00Z");
    await fs.utimes("/f.txt", past, past);

    const stat = await fs.stat("/f.txt");
    expect(stat.mtime).toEqual(past);
  });

  it("chmod throws ENOENT on missing path", async () => {
    const fs = new InMemoryFs();
    await expect(fs.chmod("/nope", 0o644)).rejects.toThrow("ENOENT");
  });
});

describe("InMemoryFs — root path guards", () => {
  it("symlink to root path throws", async () => {
    const fs = new InMemoryFs();
    await expect(fs.symlink("/target", "/")).rejects.toThrow("EEXIST");
  });

  it("link to root path throws", async () => {
    const fs = new InMemoryFs({ "/f.txt": "x" });
    await expect(fs.link("/f.txt", "/")).rejects.toThrow("EEXIST");
  });

  it("cp to root path throws", async () => {
    const fs = new InMemoryFs({ "/f.txt": "x" });
    await expect(fs.cp("/f.txt", "/")).rejects.toThrow("EISDIR");
  });
});

class FailingWriteFs {
  constructor(
    private readonly inner: InMemoryFs,
    private readonly failPath: string
  ) {}

  async readFile(path: string) {
    return this.inner.readFile(path);
  }

  async readFileBytes(path: string) {
    return this.inner.readFileBytes(path);
  }

  async writeFile(path: string, content: string) {
    if (path === this.failPath) {
      throw new Error(`simulated write failure: ${path}`);
    }
    return this.inner.writeFile(path, content);
  }

  async writeFileBytes(path: string, content: Uint8Array) {
    if (path === this.failPath) {
      throw new Error(`simulated write failure: ${path}`);
    }
    return this.inner.writeFileBytes(path, content);
  }

  async appendFile(path: string, content: string | Uint8Array) {
    return this.inner.appendFile(path, content);
  }

  async exists(path: string) {
    return this.inner.exists(path);
  }

  async stat(path: string) {
    return this.inner.stat(path);
  }

  async lstat(path: string) {
    return this.inner.lstat(path);
  }

  async mkdir(path: string, options?: { recursive?: boolean }) {
    return this.inner.mkdir(path, options);
  }

  async readdir(path: string) {
    return this.inner.readdir(path);
  }

  async readdirWithFileTypes(path: string) {
    return this.inner.readdirWithFileTypes(path);
  }

  async rm(path: string, options?: { recursive?: boolean; force?: boolean }) {
    return this.inner.rm(path, options);
  }

  async cp(src: string, dest: string, options?: { recursive?: boolean }) {
    return this.inner.cp(src, dest, options);
  }

  async mv(src: string, dest: string) {
    return this.inner.mv(src, dest);
  }

  resolvePath(base: string, path: string) {
    return this.inner.resolvePath(base, path);
  }

  async glob(pattern: string) {
    return this.inner.glob(pattern);
  }

  async chmod(path: string, mode: number) {
    return this.inner.chmod(path, mode);
  }

  async symlink(target: string, linkPath: string) {
    return this.inner.symlink(target, linkPath);
  }

  async link(existingPath: string, newPath: string) {
    return this.inner.link(existingPath, newPath);
  }

  async readlink(path: string) {
    return this.inner.readlink(path);
  }

  async realpath(path: string) {
    return this.inner.realpath(path);
  }

  async utimes(path: string, atime: Date, mtime: Date) {
    return this.inner.utimes(path, atime, mtime);
  }
}