test / files.test.ts
test / files.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const tmp = (p: string) => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), p)));
const DATA = tmp("sf-data-");
process.env.SIMPLEFILES_DATA = DATA;
const SHARED = tmp("sf-shared-");
process.env.LOCAL_ONLY_DIR = SHARED;
import { apply, appliedList, pendingList, proposeEdit, proposeNewFile, reject, undo } from "../src/edits";
import { hasCopiedText, readShown } from "../src/localonly";
import { NameIndex } from "../src/names";
import { findCommands, preprocess, runCommand, scopeFor, toolsProvider } from "../src/plugin";
import { MAX_CHANGES, maybeAutoApply } from "../src/autoapply";
import { listFolder, readFile, searchInFiles } from "../src/reading";
import { Scope, tooBroad } from "../src/scope";
function project(): string {
const root = tmp("sf-proj-");
fs.mkdirSync(path.join(root, "src"));
fs.mkdirSync(path.join(root, ".git"));
fs.mkdirSync(path.join(root, "node_modules", "dep"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# Demo\n\nFirst line.\nSecond line.\nThird line.\n");
fs.writeFileSync(path.join(root, "src", "main.ts"), "export const a = 1;\nexport const b = 2;\nexport const a2 = 1;\n");
fs.writeFileSync(path.join(root, ".git", "config"), "[core]\n");
fs.writeFileSync(path.join(root, "node_modules", "dep", "index.js"), "Second line.\n");
fs.writeFileSync(path.join(root, ".env"), "TOKEN=abc\n");
fs.writeFileSync(path.join(root, "image.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 1]));
return root;
}
const scopeOf = (...folders: string[]) => new Scope(folders.map((folder) => ({ folder, from: "settings" as const })));
const idOf = (out: string) => Number(/#(\d+)/.exec(out)![1]);
test("paths must resolve inside a granted folder, and secrets and tool folders are protected", () => {
const root = project();
const outside = tmp("sf-outside-");
fs.writeFileSync(path.join(outside, "private.txt"), "private\n");
const scope = scopeOf(root);
assert.ok("path" in scope.resolve(path.join(root, "README.md"), "read"));
assert.ok("path" in scope.resolve("README.md", "read"), "a relative path works with one granted folder");
assert.match((scope.resolve(path.join(outside, "private.txt"), "read") as { error: string }).error, /outside the granted folders/);
assert.match((scope.resolve(path.join(root, "..", path.basename(outside), "private.txt"), "read") as { error: string }).error, /outside/);
fs.symlinkSync(outside, path.join(root, "link"));
assert.match((scope.resolve(path.join(root, "link", "private.txt"), "read") as { error: string }).error, /outside/, "a link out of the folder is followed and refused");
assert.match((scope.resolve(path.join(root, ".env"), "read") as { error: string }).error, /keys or passwords/);
assert.ok("path" in scope.resolve(path.join(root, ".git", "config"), "read"));
assert.match((scope.resolve(path.join(root, ".git", "config"), "write") as { error: string }).error, /managed by tools/);
assert.match((scopeOf(root, outside).resolve("README.md", "read") as { error: string }).error, /more than one folder/);
const excluded = new Scope([{ folder: root, from: "settings" }], [path.join(root, "src")]);
assert.match((excluded.resolve(path.join(root, "src", "main.ts"), "read") as { error: string }).error, /Never look in/);
assert.ok(tooBroad(os.homedir()));
assert.ok(tooBroad(path.parse(root).root));
assert.ok(!tooBroad(root));
});
test("files are read in numbered parts, and binary files are refused", () => {
const root = project();
const big = path.join(root, "big.txt");
fs.writeFileSync(big, Array.from({ length: 700 }, (_, i) => `line ${i + 1}`).join("\n") + "\n");
const scope = scopeOf(root);
const first = readFile(scope, big);
assert.match(first, /lines: 1-300 of 700/);
assert.match(first, / 1\| line 1\n/);
assert.match(first, /call read_file again with start_line 301/);
const last = readFile(scope, big, 601);
assert.match(last, /lines: 601-700 of 700/);
assert.match(last, /end of the file/);
assert.match(readFile(scope, path.join(root, "image.png")), /binary file/);
assert.match(readFile(scope, path.join(root, "nope.txt")), /does not exist/);
const list = listFolder(scope, root);
assert.match(list, / src\/\n/);
assert.match(list, / README\.md \(\d+ B\)/);
});
test("searching file contents skips .git, node_modules and secrets", async () => {
const root = project();
const out = await searchInFiles(scopeOf(root), root, "second line");
assert.match(out, /README\.md:4: Second line\./);
assert.doesNotMatch(out, /node_modules/);
const none = await searchInFiles(scopeOf(root), root, "TOKEN");
assert.match(none, /^status: no_results/);
});
test("an edit is only proposed for a passage that appears exactly once", () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "src", "main.ts");
assert.match(proposeEdit(scope, file, "export const c = 3;", "x", "r"), /not in the file/);
assert.match(proposeEdit(scope, file, " = 1;", " = 9;", "r"), /appears 2 times/);
assert.match(proposeEdit(scope, file, " 1| export const a = 1;", "x", "r"), /not in the file[\s\S]*line numbers/);
assert.match(proposeEdit(scope, path.join(root, ".git", "config"), "[core]", "[x]", "r"), /managed by tools/);
const out = proposeEdit(scope, file, "export const b = 2;", "export const b = 3;", "Bump b");
assert.match(out, /^status: proposed/);
assert.match(out, /- 2\| export const b = 2;\n\+ \| export const b = 3;/);
assert.match(out, /Nothing has changed yet/);
assert.equal(fs.readFileSync(file, "utf8").includes("b = 2"), true, "proposing changes nothing");
reject("all");
});
test("applying backs up, writes, logs, and undo restores", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "README.md");
const original = fs.readFileSync(file, "utf8");
const id = idOf(proposeEdit(scope, file, "Second line.", "Second line, edited.", "Edit the second line"));
const result = await apply(scope, [id]);
assert.match(result, new RegExp(`#${id} applied`));
assert.equal(fs.readFileSync(file, "utf8"), original.replace("Second line.", "Second line, edited."));
const last = appliedList().at(-1)!;
assert.equal(fs.readFileSync(path.join(last.backup, "README.md"), "utf8"), original, "the backup holds the original");
assert.match(fs.readFileSync(path.join(DATA, "changes.md"), "utf8"), new RegExp(`#${id} edited .*Edit the second line`));
assert.equal(pendingList().length, 0);
assert.match(undo(), new RegExp(`Undid #${id}`));
assert.equal(fs.readFileSync(file, "utf8"), original);
assert.deepEqual(fs.readdirSync(root).filter((f) => f.includes("simplefiles")), [], "no temporary files left behind");
});
test("undo refuses when the file changed after simpleFiles wrote it", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "README.md");
await apply(scope, [idOf(proposeEdit(scope, file, "Third line.", "Third line!", "r"))]);
fs.appendFileSync(file, "Added by hand.\n");
assert.match(undo(), /has changed since simpleFiles wrote it/);
assert.match(fs.readFileSync(file, "utf8"), /Added by hand/);
});
test("two edits to one file both apply, and an outdated one is refused", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "src", "main.ts");
const a = idOf(proposeEdit(scope, file, "export const a = 1;\n", "export const a = 10;\n", "a"));
const b = idOf(proposeEdit(scope, file, "export const b = 2;", "export const b = 20;", "b"));
const stale = idOf(proposeEdit(scope, file, "export const a2 = 1;", "export const a2 = 5;", "a2"));
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("a2 = 1", "a2 = 7"));
const out = await apply(scope, "all");
assert.match(out, new RegExp(`#${a} applied`));
assert.match(out, new RegExp(`#${b} applied`));
assert.match(out, new RegExp(`#${stale} not applied: the passage now appears 0 times`));
assert.equal(fs.readFileSync(file, "utf8"), "export const a = 10;\nexport const b = 20;\nexport const a2 = 7;\n");
assert.equal(pendingList().length, 0);
});
test("Windows line endings are kept", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "crlf.txt");
fs.writeFileSync(file, "one\r\ntwo\r\nthree\r\n");
await apply(scope, [idOf(proposeEdit(scope, file, "two\nthree", "two\nTHREE", "r"))]);
assert.equal(fs.readFileSync(file, "utf8"), "one\r\ntwo\r\nTHREE\r\n");
});
test("a new file is created on approval, and undo moves it into the backups", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "docs", "notes.md");
assert.match(proposeNewFile(scope, path.join(root, "README.md"), "x", "r"), /already exists/);
const id = idOf(proposeNewFile(scope, file, "# Notes\n", "Start notes"));
assert.equal(fs.existsSync(file), false);
assert.match(await apply(scope, [id]), /created/);
assert.equal(fs.readFileSync(file, "utf8"), "# Notes\n");
assert.match(undo(), /was removed \(a copy is kept/);
assert.equal(fs.existsSync(file), false);
const found: string[] = [];
const walk = (d: string) => fs.readdirSync(d, { withFileTypes: true }).forEach((e) => (e.isDirectory() ? walk(path.join(d, e.name)) : found.push(path.join(d, e.name))));
walk(path.join(DATA, "backups"));
assert.ok(found.some((f) => f.endsWith(path.join("undone", "docs", "notes.md"))), "the undone file is kept in the backups");
});
test("/files commands grant folders, show and apply proposals, and refuse broad grants", async () => {
const root = project();
const settings = { folders: [], searchRoots: [root], excludes: [] };
assert.match(await runCommand(settings, "/files grant " + os.homedir()), /too broad/);
assert.match(await runCommand(settings, `/files grant ${root}`), /^Granted: /);
const { scope } = scopeFor(settings);
assert.ok(scope.grants.some((g) => g.folder === root && g.from === "chat"));
const id = idOf(proposeEdit(scope, path.join(root, "README.md"), "First line.", "First line!", "Exclaim"));
assert.match(await runCommand(settings, "/files pending"), new RegExp(`#${id} edit .*README\\.md[\\s\\S]*\\+ \\| First line!`));
assert.match(await runCommand(settings, "/files apply"), new RegExp(`#${id} applied`), "with one waiting, /files apply needs no number");
assert.match(await runCommand(settings, "/files status"), new RegExp(`#${id} .* edited .*README\\.md`));
assert.match(await runCommand(settings, "/files undo"), /Undid/);
assert.match(await runCommand(settings, `/files revoke ${root}`), /^Revoked/);
assert.equal(scopeFor(settings).scope.grants.length, 0);
assert.match(await runCommand(settings, "/files"), /simpleFiles commands/);
});
test("the preprocessor answers /files commands and leaves other messages alone", async () => {
const ctl = { getGlobalPluginConfig: () => ({ get: () => "" }) } as never;
const plain = { getText: () => "please read my files", asMutableCopy: () => ({}) } as never;
assert.equal(await preprocess(ctl, plain), plain);
let replaced = "";
const cmd = { getText: () => "/files status", asMutableCopy: () => ({ replaceText: (t: string) => (replaced = t) }) } as never;
await preprocess(ctl, cmd);
assert.match(replaced, /^privacy: local, from simplefiles\n\[simpleFiles handled a command from the user\./);
assert.match(replaced, /granted folders:/);
});
test("several /files commands in one message each take only their own numbers", () => {
// Messages that went wrong before: every number in the message was used by the first command.
assert.deepEqual(findCommands("/files apply 29 31 32 33 34 36 /files reject 30 35"), { commands: ["/files apply 29 31 32 33 34 36", "/files reject 30 35"], rest: "" });
assert.deepEqual(findCommands("/files apply 37 38. /files reject 6 26. /files keep 17"), { commands: ["/files apply 37 38", "/files reject 6 26", "/files keep 17"], rest: "" });
assert.deepEqual(findCommands("/files reject 80\n/files apply 77 78 79 81"), { commands: ["/files reject 80", "/files apply 77 78 79 81"], rest: "" });
assert.deepEqual(findCommands("/files apply 28 and then give me the esbuild command"), { commands: ["/files apply 28"], rest: "and then give me the esbuild command" });
assert.deepEqual(findCommands("/files apply #4 and #5, then run the tests"), { commands: ["/files apply #4 and #5"], rest: "then run the tests" });
assert.deepEqual(findCommands("Do that. /files apply 56"), { commands: ["/files apply 56"], rest: "Do that." });
assert.deepEqual(findCommands("/files apply all except 4"), { commands: ["/files apply all except 4"], rest: "" });
assert.deepEqual(findCommands("/files trust C:\\proj run it /files apply 3"), { commands: ["/files trust C:\\proj run it", "/files apply 3"], rest: "" });
assert.deepEqual(findCommands("/files undo. then look again"), { commands: ["/files undo"], rest: "then look again" });
});
test("the preprocessor runs each command in order, and refuses apply and reject of the same change", async () => {
const root = project();
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => (k === "folders" ? root : "") }) } as never;
const { scope } = scopeFor({ folders: [root], searchRoots: [], excludes: [] });
const file = path.join(root, "README.md");
for (const p of pendingList()) reject([p.id]);
const a = idOf(proposeEdit(scope, file, "First line.", "First!", "a"));
const b = idOf(proposeEdit(scope, file, "Second line.", "Second!", "b"));
const c = idOf(proposeEdit(scope, file, "Third line.", "Third!", "c"));
const send = async (text: string) => {
let replaced = "";
await preprocess(ctl, { getText: () => text, asMutableCopy: () => ({ replaceText: (t: string) => (replaced = t) }) } as never);
return replaced;
};
const clash = await send(`/files apply ${a} ${b} /files reject ${b}`);
assert.match(clash, /Nothing was applied or rejected: this message both applies and rejects #\d+/);
assert.equal(pendingList().length, 3, "a clash changes nothing");
const out = await send(`/files apply ${a} ${c} /files reject ${b} and tell me what changed`);
assert.match(out, /handled 2 commands/);
assert.match(out, new RegExp(`> /files apply ${a} ${c}\\n[\\s\\S]*#${a} applied[\\s\\S]*> /files reject ${b}\\nDropped 1`));
assert.match(out, /\[The user's message also said:\]\nand tell me what changed$/);
assert.equal(pendingList().length, 0);
assert.deepEqual(appliedList().slice(-2).map((x) => x.id), [a, c]);
});
test("propose_new_file turns away a retyped copy of a file that was read, and points to copy_files", () => {
const root = project();
const { scope } = scopeFor({ folders: [root], searchRoots: [], excludes: [] });
const body = Array.from({ length: 60 }, (_, i) => `export const value${i} = ${i * 7}; // line ${i}`).join("\n") + "\n";
const src = path.join(root, "src.ts");
fs.writeFileSync(src, body.replace(/\n/g, "\r\n"));
readFile(scope, src, 1, 20);
const exact = proposeNewFile(scope, path.join(root, "archive", "src.ts"), body, "archive copy");
assert.match(exact, /^status: refused/);
assert.match(exact, /an exact copy of .*src\.ts/);
assert.match(exact, /copy_files with sources/);
// A retype that dropped a line is still a copy.
const dropped = body.split("\n").filter((_, i) => i !== 30).join("\n");
const near = proposeNewFile(scope, path.join(root, "archive", "renamed.ts"), dropped, "archive copy");
assert.match(near, /a near copy of/);
assert.match(near, /under its own name, src\.ts/);
assert.match(near, /propose_edit once the copy is applied/);
// A genuinely new file, and a short one, go through.
assert.match(proposeNewFile(scope, path.join(root, "other.ts"), body.replace(/value/g, "other").replace(/\d+;/g, "0;"), "new"), /^status: proposed/);
assert.match(proposeNewFile(scope, path.join(root, "tiny.ts"), "export const value0 = 0; // line 0\n", "small"), /^status: proposed/);
// Once the source changes on disk, the old read no longer counts.
fs.writeFileSync(src, "something else entirely\n");
assert.match(proposeNewFile(scope, path.join(root, "archive2", "src.ts"), body, "archive copy"), /^status: proposed/); reject("all");
});
test("after an update, the next message tells the model about the new tools, once", async () => {
const before = process.env.SIMPLEFILES_DATA;
process.env.SIMPLEFILES_DATA = tmp("sf-data-news-");
const root = project();
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => (k === "folders" ? root : "") }) } as never;
const appended: string[] = [];
const msg = () => ({ getText: () => "carry on", asMutableCopy: () => ({ appendText: (t: string) => appended.push(t) }) }) as never;
const first = await preprocess(ctl, msg());
assert.notEqual(first, undefined);
assert.equal(appended.length, 1);
assert.match(appended[0], /simpleFiles was updated to \d+\.\d+\.\d+\. New for you: copy_files/);
const plain = msg();
assert.equal(await preprocess(ctl, plain), plain, "said once only");
const noGrants = { getGlobalPluginConfig: () => ({ get: () => "" }) } as never;
process.env.SIMPLEFILES_DATA = tmp("sf-data-news2-");
const plain2 = msg();
assert.equal(await preprocess(noGrants, plain2), plain2, "not said where no files can be worked on");
process.env.SIMPLEFILES_DATA = before;
});
test("with /files autoapply on for a folder, changes there take effect at once, and only there", async () => {
const root = project();
const other = project();
const settings = { folders: [root, other], searchRoots: [], excludes: [] };
const { scope } = scopeFor(settings);
for (const p of pendingList()) reject([p.id]);
const readme = path.join(root, "README.md");
// Off: a proposal waits.
assert.match(await maybeAutoApply(scope, proposeEdit(scope, readme, "First line.", "First!", "a")), /^status: proposed/);
reject("all");
assert.match(await runCommand(settings, "/files autoapply"), /Auto-apply is off/);
assert.match(await runCommand(settings, `/files autoapply ${root} soon`), /not a length of time/);
assert.match(await runCommand(settings, `/files autoapply ${os.homedir()}`), /outside the granted folders|too broad/);
assert.match(await runCommand(settings, `/files autoapply ${root} 30m`), /Auto-apply is on for .* until \d\d:\d\d, for up to 60 changes/);
const edited = await maybeAutoApply(scope, proposeEdit(scope, readme, "First line.", "First!", "a"));
assert.match(edited, /^status: applied\n#\d+ applied: edited/);
assert.match(edited, /Carry on with the task; do not ask the user to apply it/);
assert.match(fs.readFileSync(readme, "utf8"), /First!/);
assert.equal(pendingList().length, 0);
const created = await maybeAutoApply(scope, proposeNewFile(scope, path.join(root, "src", "new.ts"), "export {};\n", "b"));
assert.match(created, /^status: applied\n#\d+ applied: created/);
assert.match(await runCommand(settings, "/files status"), /auto-apply: \n .* until \d\d:\d\d, 58 changes left/);
assert.match(await runCommand(settings, "/files undo"), /Undid/);
assert.ok(!fs.existsSync(path.join(root, "src", "new.ts")), "undo still works");
// Another granted folder still asks.
assert.match(await maybeAutoApply(scope, proposeEdit(scope, path.join(other, "README.md"), "First line.", "First!", "c")), /^status: proposed/);
reject("all");
assert.match(await runCommand(settings, "/files autoapply off"), /Auto-apply is off/);
assert.match(await maybeAutoApply(scope, proposeEdit(scope, readme, "Second line.", "Second!", "e")), /^status: proposed/);
reject("all");
});
test("auto-apply ends after its limit of changes", async () => {
const root = project();
const settings = { folders: [root], searchRoots: [], excludes: [] };
const { scope } = scopeFor(settings);
await runCommand(settings, `/files autoapply ${root}`);
for (let i = 0; i < MAX_CHANGES; i++) {
assert.match(await maybeAutoApply(scope, proposeNewFile(scope, path.join(root, "many", `f${i}.txt`), `${i}\n`, "n")), /^status: applied/);
}
assert.match(await maybeAutoApply(scope, proposeNewFile(scope, path.join(root, "many", "last.txt"), "x\n", "n")), /^status: proposed/);
reject("all");
await runCommand(settings, "/files autoapply off");
});
test("the file name index skips hidden, tool and excluded folders, and resumes after a stop", async () => {
// Its own data folder, so builds started by other tests cannot touch its index.
process.env.SIMPLEFILES_DATA = tmp("sf-data-names-");
const root = tmp("sf-names-");
for (const d of ["Projects/simpleSearch/source", "Projects/notes", ".ssh", "AppData/Local/browser", "node_modules/x", "Private/taxes"]) {
fs.mkdirSync(path.join(root, d), { recursive: true });
}
fs.writeFileSync(path.join(root, "Projects/simpleSearch/README.md"), "");
fs.writeFileSync(path.join(root, "Projects/simpleSearch/source/plugin.ts"), "");
fs.writeFileSync(path.join(root, "Projects/notes/readme-draft.md"), "");
fs.writeFileSync(path.join(root, ".ssh/id_ed25519"), "");
fs.writeFileSync(path.join(root, "AppData/Local/browser/cookies.sqlite"), "");
fs.writeFileSync(path.join(root, "Private/taxes/2025.pdf"), "");
const names = new NameIndex([root], [path.join(root, "Private")]);
assert.equal(names.find("readme"), null, "no index yet");
await names.buildNow();
const all = names.find(path.basename(root), 100)!;
assert.ok(all.some((p) => p.endsWith("plugin.ts")));
assert.ok(!all.some((p) => /\.ssh|AppData|node_modules|taxes/.test(p)), "skipped folders never appear");
const readme = names.find("simplesearch readme")!;
assert.equal(path.basename(readme[0]), "README.md");
assert.ok(names.find(".md")!.every((p) => p.endsWith(".md")));
// Simulate a stop partway: a saved queue and some saved entries; the next start carries on.
const dir = path.join(process.env.SIMPLEFILES_DATA!, "names");
fs.writeFileSync(path.join(dir, "partial.txt"), path.join(root, "Projects") + path.sep + "\n");
fs.writeFileSync(path.join(dir, "build.json"), JSON.stringify({ roots: [root], excludes: [path.join(root, "Private")], queue: [path.join(root, "Projects")], count: 1, startedAt: Date.now() }));
const resumed = new NameIndex([root], [path.join(root, "Private")]);
resumed.refreshIfNeeded();
for (let i = 0; i < 100 && resumed.isBuilding(); i++) await new Promise((r) => setTimeout(r, 20));
assert.ok(resumed.find("plugin")!.some((p) => p.endsWith("plugin.ts")));
process.env.SIMPLEFILES_DATA = DATA;
});
test("the plugin's tools, without the development ones when they are off, and find_files marks what is not granted", async () => {
const root = project();
const values: Record<string, string> = { folders: "", searchRoots: root, excludes: "" };
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => values[k] }) } as never;
const tools = (await toolsProvider(ctl)) as unknown as Array<{ name: string; implementation: (a: unknown, c: unknown) => Promise<string> | string }>;
assert.deepEqual(tools.map((t) => t.name), ["find_files", "list_folder", "read_file", "search_in_files", "propose_edit", "propose_new_file", "copy_files", "read_form", "fill_form", "list_proposals"], "no granted folder holds a project, so run_script is not offered");
const off = (await toolsProvider({ getGlobalPluginConfig: () => ({ get: (k: string) => (k === "devTools" ? false : values[k]) }) } as never)) as unknown as Array<{ name: string }>;
assert.equal(off.length, 10);
const find = tools.find((t) => t.name === "find_files")!;
let out = await find.implementation({ query: "readme" }, {});
for (let i = 0; i < 100 && /index_building/.test(out); i++) {
await new Promise((r) => setTimeout(r, 30));
out = await find.implementation({ query: "readme" }, {});
}
assert.match(out, /README\.md \(not granted\)/);
assert.match(out, /\/files grant/);
assert.match(await tools.find((t) => t.name === "read_file")!.implementation({ path: path.join(root, "README.md") }, {}), /outside the granted folders/);
});
test("everything the tools show is marked as local, and its identifiers fingerprinted for the search plugin", async () => {
const root = project();
fs.writeFileSync(path.join(root, "notes.txt"), "Meet the Harrowgate supplier about the walnut cabinet order on Thursday. Order 88412397.\n");
const values: Record<string, string> = { folders: root, searchRoots: root, excludes: "" };
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => values[k] }) } as never;
const tools = (await toolsProvider(ctl)) as unknown as Array<{ name: string; implementation: (a: unknown, c: unknown) => Promise<string> | string }>;
const before = Date.now();
const out = await tools.find((t) => t.name === "read_file")!.implementation({ path: path.join(root, "notes.txt") }, {});
assert.match(out, /walnut cabinet/);
assert.match(out, /\nprivacy: local, from simplefiles$/);
assert.ok(readShown().simplefiles >= before);
assert.ok(hasCopiedText("status of order 88412397"), "identifiers are recorded");
assert.ok(!hasCopiedText("harrowgate supplier about the walnut cabinet order"), "ordinary wording from files is not");
});
test("/files apply and reject understand numbers and 'all' written loosely, and refuse exceptions", async () => {
const root = project();
const settings = { folders: [root], searchRoots: [root], excludes: [] };
const { scope } = scopeFor(settings);
const file = path.join(root, "README.md");
const a = idOf(proposeEdit(scope, file, "First line.", "First!", "a"));
const b = idOf(proposeEdit(scope, file, "Second line.", "Second!", "b"));
const c = idOf(proposeEdit(scope, file, "Third line.", "Third!", "c"));
assert.match(await runCommand(settings, `/files apply all except #${b}`), /rather than an exception/);
assert.equal(pendingList().length, 3, "an exception applies nothing");
const out = await runCommand(settings, `/files apply #${a} and ${c} please`);
assert.match(out, new RegExp(`#${a} applied[\\s\\S]*#${c} applied`));
assert.deepEqual(pendingList().map((p) => p.id), [b]);
assert.match(await runCommand(settings, "/files reject everything"), /Dropped 1/);
const d = idOf(proposeEdit(scope, file, "# Demo", "# Demo!", "d"));
const e = idOf(proposeNewFile(scope, path.join(root, "new.md"), "x\n", "e"));
assert.match(await runCommand(settings, "/files apply all please"), new RegExp(`#${d} applied[\\s\\S]*#${e} applied`));
});
import { test } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const tmp = (p: string) => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), p)));
const DATA = tmp("sf-data-");
process.env.SIMPLEFILES_DATA = DATA;
const SHARED = tmp("sf-shared-");
process.env.LOCAL_ONLY_DIR = SHARED;
import { apply, appliedList, pendingList, proposeEdit, proposeNewFile, reject, undo } from "../src/edits";
import { hasCopiedText, readShown } from "../src/localonly";
import { NameIndex } from "../src/names";
import { findCommands, preprocess, runCommand, scopeFor, toolsProvider } from "../src/plugin";
import { MAX_CHANGES, maybeAutoApply } from "../src/autoapply";
import { listFolder, readFile, searchInFiles } from "../src/reading";
import { Scope, tooBroad } from "../src/scope";
function project(): string {
const root = tmp("sf-proj-");
fs.mkdirSync(path.join(root, "src"));
fs.mkdirSync(path.join(root, ".git"));
fs.mkdirSync(path.join(root, "node_modules", "dep"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# Demo\n\nFirst line.\nSecond line.\nThird line.\n");
fs.writeFileSync(path.join(root, "src", "main.ts"), "export const a = 1;\nexport const b = 2;\nexport const a2 = 1;\n");
fs.writeFileSync(path.join(root, ".git", "config"), "[core]\n");
fs.writeFileSync(path.join(root, "node_modules", "dep", "index.js"), "Second line.\n");
fs.writeFileSync(path.join(root, ".env"), "TOKEN=abc\n");
fs.writeFileSync(path.join(root, "image.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 1]));
return root;
}
const scopeOf = (...folders: string[]) => new Scope(folders.map((folder) => ({ folder, from: "settings" as const })));
const idOf = (out: string) => Number(/#(\d+)/.exec(out)![1]);
test("paths must resolve inside a granted folder, and secrets and tool folders are protected", () => {
const root = project();
const outside = tmp("sf-outside-");
fs.writeFileSync(path.join(outside, "private.txt"), "private\n");
const scope = scopeOf(root);
assert.ok("path" in scope.resolve(path.join(root, "README.md"), "read"));
assert.ok("path" in scope.resolve("README.md", "read"), "a relative path works with one granted folder");
assert.match((scope.resolve(path.join(outside, "private.txt"), "read") as { error: string }).error, /outside the granted folders/);
assert.match((scope.resolve(path.join(root, "..", path.basename(outside), "private.txt"), "read") as { error: string }).error, /outside/);
fs.symlinkSync(outside, path.join(root, "link"));
assert.match((scope.resolve(path.join(root, "link", "private.txt"), "read") as { error: string }).error, /outside/, "a link out of the folder is followed and refused");
assert.match((scope.resolve(path.join(root, ".env"), "read") as { error: string }).error, /keys or passwords/);
assert.ok("path" in scope.resolve(path.join(root, ".git", "config"), "read"));
assert.match((scope.resolve(path.join(root, ".git", "config"), "write") as { error: string }).error, /managed by tools/);
assert.match((scopeOf(root, outside).resolve("README.md", "read") as { error: string }).error, /more than one folder/);
const excluded = new Scope([{ folder: root, from: "settings" }], [path.join(root, "src")]);
assert.match((excluded.resolve(path.join(root, "src", "main.ts"), "read") as { error: string }).error, /Never look in/);
assert.ok(tooBroad(os.homedir()));
assert.ok(tooBroad(path.parse(root).root));
assert.ok(!tooBroad(root));
});
test("files are read in numbered parts, and binary files are refused", () => {
const root = project();
const big = path.join(root, "big.txt");
fs.writeFileSync(big, Array.from({ length: 700 }, (_, i) => `line ${i + 1}`).join("\n") + "\n");
const scope = scopeOf(root);
const first = readFile(scope, big);
assert.match(first, /lines: 1-300 of 700/);
assert.match(first, / 1\| line 1\n/);
assert.match(first, /call read_file again with start_line 301/);
const last = readFile(scope, big, 601);
assert.match(last, /lines: 601-700 of 700/);
assert.match(last, /end of the file/);
assert.match(readFile(scope, path.join(root, "image.png")), /binary file/);
assert.match(readFile(scope, path.join(root, "nope.txt")), /does not exist/);
const list = listFolder(scope, root);
assert.match(list, / src\/\n/);
assert.match(list, / README\.md \(\d+ B\)/);
});
test("searching file contents skips .git, node_modules and secrets", async () => {
const root = project();
const out = await searchInFiles(scopeOf(root), root, "second line");
assert.match(out, /README\.md:4: Second line\./);
assert.doesNotMatch(out, /node_modules/);
const none = await searchInFiles(scopeOf(root), root, "TOKEN");
assert.match(none, /^status: no_results/);
});
test("an edit is only proposed for a passage that appears exactly once", () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "src", "main.ts");
assert.match(proposeEdit(scope, file, "export const c = 3;", "x", "r"), /not in the file/);
assert.match(proposeEdit(scope, file, " = 1;", " = 9;", "r"), /appears 2 times/);
assert.match(proposeEdit(scope, file, " 1| export const a = 1;", "x", "r"), /not in the file[\s\S]*line numbers/);
assert.match(proposeEdit(scope, path.join(root, ".git", "config"), "[core]", "[x]", "r"), /managed by tools/);
const out = proposeEdit(scope, file, "export const b = 2;", "export const b = 3;", "Bump b");
assert.match(out, /^status: proposed/);
assert.match(out, /- 2\| export const b = 2;\n\+ \| export const b = 3;/);
assert.match(out, /Nothing has changed yet/);
assert.equal(fs.readFileSync(file, "utf8").includes("b = 2"), true, "proposing changes nothing");
reject("all");
});
test("applying backs up, writes, logs, and undo restores", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "README.md");
const original = fs.readFileSync(file, "utf8");
const id = idOf(proposeEdit(scope, file, "Second line.", "Second line, edited.", "Edit the second line"));
const result = await apply(scope, [id]);
assert.match(result, new RegExp(`#${id} applied`));
assert.equal(fs.readFileSync(file, "utf8"), original.replace("Second line.", "Second line, edited."));
const last = appliedList().at(-1)!;
assert.equal(fs.readFileSync(path.join(last.backup, "README.md"), "utf8"), original, "the backup holds the original");
assert.match(fs.readFileSync(path.join(DATA, "changes.md"), "utf8"), new RegExp(`#${id} edited .*Edit the second line`));
assert.equal(pendingList().length, 0);
assert.match(undo(), new RegExp(`Undid #${id}`));
assert.equal(fs.readFileSync(file, "utf8"), original);
assert.deepEqual(fs.readdirSync(root).filter((f) => f.includes("simplefiles")), [], "no temporary files left behind");
});
test("undo refuses when the file changed after simpleFiles wrote it", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "README.md");
await apply(scope, [idOf(proposeEdit(scope, file, "Third line.", "Third line!", "r"))]);
fs.appendFileSync(file, "Added by hand.\n");
assert.match(undo(), /has changed since simpleFiles wrote it/);
assert.match(fs.readFileSync(file, "utf8"), /Added by hand/);
});
test("two edits to one file both apply, and an outdated one is refused", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "src", "main.ts");
const a = idOf(proposeEdit(scope, file, "export const a = 1;\n", "export const a = 10;\n", "a"));
const b = idOf(proposeEdit(scope, file, "export const b = 2;", "export const b = 20;", "b"));
const stale = idOf(proposeEdit(scope, file, "export const a2 = 1;", "export const a2 = 5;", "a2"));
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("a2 = 1", "a2 = 7"));
const out = await apply(scope, "all");
assert.match(out, new RegExp(`#${a} applied`));
assert.match(out, new RegExp(`#${b} applied`));
assert.match(out, new RegExp(`#${stale} not applied: the passage now appears 0 times`));
assert.equal(fs.readFileSync(file, "utf8"), "export const a = 10;\nexport const b = 20;\nexport const a2 = 7;\n");
assert.equal(pendingList().length, 0);
});
test("Windows line endings are kept", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "crlf.txt");
fs.writeFileSync(file, "one\r\ntwo\r\nthree\r\n");
await apply(scope, [idOf(proposeEdit(scope, file, "two\nthree", "two\nTHREE", "r"))]);
assert.equal(fs.readFileSync(file, "utf8"), "one\r\ntwo\r\nTHREE\r\n");
});
test("a new file is created on approval, and undo moves it into the backups", async () => {
const root = project();
const scope = scopeOf(root);
const file = path.join(root, "docs", "notes.md");
assert.match(proposeNewFile(scope, path.join(root, "README.md"), "x", "r"), /already exists/);
const id = idOf(proposeNewFile(scope, file, "# Notes\n", "Start notes"));
assert.equal(fs.existsSync(file), false);
assert.match(await apply(scope, [id]), /created/);
assert.equal(fs.readFileSync(file, "utf8"), "# Notes\n");
assert.match(undo(), /was removed \(a copy is kept/);
assert.equal(fs.existsSync(file), false);
const found: string[] = [];
const walk = (d: string) => fs.readdirSync(d, { withFileTypes: true }).forEach((e) => (e.isDirectory() ? walk(path.join(d, e.name)) : found.push(path.join(d, e.name))));
walk(path.join(DATA, "backups"));
assert.ok(found.some((f) => f.endsWith(path.join("undone", "docs", "notes.md"))), "the undone file is kept in the backups");
});
test("/files commands grant folders, show and apply proposals, and refuse broad grants", async () => {
const root = project();
const settings = { folders: [], searchRoots: [root], excludes: [] };
assert.match(await runCommand(settings, "/files grant " + os.homedir()), /too broad/);
assert.match(await runCommand(settings, `/files grant ${root}`), /^Granted: /);
const { scope } = scopeFor(settings);
assert.ok(scope.grants.some((g) => g.folder === root && g.from === "chat"));
const id = idOf(proposeEdit(scope, path.join(root, "README.md"), "First line.", "First line!", "Exclaim"));
assert.match(await runCommand(settings, "/files pending"), new RegExp(`#${id} edit .*README\\.md[\\s\\S]*\\+ \\| First line!`));
assert.match(await runCommand(settings, "/files apply"), new RegExp(`#${id} applied`), "with one waiting, /files apply needs no number");
assert.match(await runCommand(settings, "/files status"), new RegExp(`#${id} .* edited .*README\\.md`));
assert.match(await runCommand(settings, "/files undo"), /Undid/);
assert.match(await runCommand(settings, `/files revoke ${root}`), /^Revoked/);
assert.equal(scopeFor(settings).scope.grants.length, 0);
assert.match(await runCommand(settings, "/files"), /simpleFiles commands/);
});
test("the preprocessor answers /files commands and leaves other messages alone", async () => {
const ctl = { getGlobalPluginConfig: () => ({ get: () => "" }) } as never;
const plain = { getText: () => "please read my files", asMutableCopy: () => ({}) } as never;
assert.equal(await preprocess(ctl, plain), plain);
let replaced = "";
const cmd = { getText: () => "/files status", asMutableCopy: () => ({ replaceText: (t: string) => (replaced = t) }) } as never;
await preprocess(ctl, cmd);
assert.match(replaced, /^privacy: local, from simplefiles\n\[simpleFiles handled a command from the user\./);
assert.match(replaced, /granted folders:/);
});
test("several /files commands in one message each take only their own numbers", () => {
// Messages that went wrong before: every number in the message was used by the first command.
assert.deepEqual(findCommands("/files apply 29 31 32 33 34 36 /files reject 30 35"), { commands: ["/files apply 29 31 32 33 34 36", "/files reject 30 35"], rest: "" });
assert.deepEqual(findCommands("/files apply 37 38. /files reject 6 26. /files keep 17"), { commands: ["/files apply 37 38", "/files reject 6 26", "/files keep 17"], rest: "" });
assert.deepEqual(findCommands("/files reject 80\n/files apply 77 78 79 81"), { commands: ["/files reject 80", "/files apply 77 78 79 81"], rest: "" });
assert.deepEqual(findCommands("/files apply 28 and then give me the esbuild command"), { commands: ["/files apply 28"], rest: "and then give me the esbuild command" });
assert.deepEqual(findCommands("/files apply #4 and #5, then run the tests"), { commands: ["/files apply #4 and #5"], rest: "then run the tests" });
assert.deepEqual(findCommands("Do that. /files apply 56"), { commands: ["/files apply 56"], rest: "Do that." });
assert.deepEqual(findCommands("/files apply all except 4"), { commands: ["/files apply all except 4"], rest: "" });
assert.deepEqual(findCommands("/files trust C:\\proj run it /files apply 3"), { commands: ["/files trust C:\\proj run it", "/files apply 3"], rest: "" });
assert.deepEqual(findCommands("/files undo. then look again"), { commands: ["/files undo"], rest: "then look again" });
});
test("the preprocessor runs each command in order, and refuses apply and reject of the same change", async () => {
const root = project();
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => (k === "folders" ? root : "") }) } as never;
const { scope } = scopeFor({ folders: [root], searchRoots: [], excludes: [] });
const file = path.join(root, "README.md");
for (const p of pendingList()) reject([p.id]);
const a = idOf(proposeEdit(scope, file, "First line.", "First!", "a"));
const b = idOf(proposeEdit(scope, file, "Second line.", "Second!", "b"));
const c = idOf(proposeEdit(scope, file, "Third line.", "Third!", "c"));
const send = async (text: string) => {
let replaced = "";
await preprocess(ctl, { getText: () => text, asMutableCopy: () => ({ replaceText: (t: string) => (replaced = t) }) } as never);
return replaced;
};
const clash = await send(`/files apply ${a} ${b} /files reject ${b}`);
assert.match(clash, /Nothing was applied or rejected: this message both applies and rejects #\d+/);
assert.equal(pendingList().length, 3, "a clash changes nothing");
const out = await send(`/files apply ${a} ${c} /files reject ${b} and tell me what changed`);
assert.match(out, /handled 2 commands/);
assert.match(out, new RegExp(`> /files apply ${a} ${c}\\n[\\s\\S]*#${a} applied[\\s\\S]*> /files reject ${b}\\nDropped 1`));
assert.match(out, /\[The user's message also said:\]\nand tell me what changed$/);
assert.equal(pendingList().length, 0);
assert.deepEqual(appliedList().slice(-2).map((x) => x.id), [a, c]);
});
test("propose_new_file turns away a retyped copy of a file that was read, and points to copy_files", () => {
const root = project();
const { scope } = scopeFor({ folders: [root], searchRoots: [], excludes: [] });
const body = Array.from({ length: 60 }, (_, i) => `export const value${i} = ${i * 7}; // line ${i}`).join("\n") + "\n";
const src = path.join(root, "src.ts");
fs.writeFileSync(src, body.replace(/\n/g, "\r\n"));
readFile(scope, src, 1, 20);
const exact = proposeNewFile(scope, path.join(root, "archive", "src.ts"), body, "archive copy");
assert.match(exact, /^status: refused/);
assert.match(exact, /an exact copy of .*src\.ts/);
assert.match(exact, /copy_files with sources/);
// A retype that dropped a line is still a copy.
const dropped = body.split("\n").filter((_, i) => i !== 30).join("\n");
const near = proposeNewFile(scope, path.join(root, "archive", "renamed.ts"), dropped, "archive copy");
assert.match(near, /a near copy of/);
assert.match(near, /under its own name, src\.ts/);
assert.match(near, /propose_edit once the copy is applied/);
// A genuinely new file, and a short one, go through.
assert.match(proposeNewFile(scope, path.join(root, "other.ts"), body.replace(/value/g, "other").replace(/\d+;/g, "0;"), "new"), /^status: proposed/);
assert.match(proposeNewFile(scope, path.join(root, "tiny.ts"), "export const value0 = 0; // line 0\n", "small"), /^status: proposed/);
// Once the source changes on disk, the old read no longer counts.
fs.writeFileSync(src, "something else entirely\n");
assert.match(proposeNewFile(scope, path.join(root, "archive2", "src.ts"), body, "archive copy"), /^status: proposed/); reject("all");
});
test("after an update, the next message tells the model about the new tools, once", async () => {
const before = process.env.SIMPLEFILES_DATA;
process.env.SIMPLEFILES_DATA = tmp("sf-data-news-");
const root = project();
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => (k === "folders" ? root : "") }) } as never;
const appended: string[] = [];
const msg = () => ({ getText: () => "carry on", asMutableCopy: () => ({ appendText: (t: string) => appended.push(t) }) }) as never;
const first = await preprocess(ctl, msg());
assert.notEqual(first, undefined);
assert.equal(appended.length, 1);
assert.match(appended[0], /simpleFiles was updated to \d+\.\d+\.\d+\. New for you: copy_files/);
const plain = msg();
assert.equal(await preprocess(ctl, plain), plain, "said once only");
const noGrants = { getGlobalPluginConfig: () => ({ get: () => "" }) } as never;
process.env.SIMPLEFILES_DATA = tmp("sf-data-news2-");
const plain2 = msg();
assert.equal(await preprocess(noGrants, plain2), plain2, "not said where no files can be worked on");
process.env.SIMPLEFILES_DATA = before;
});
test("with /files autoapply on for a folder, changes there take effect at once, and only there", async () => {
const root = project();
const other = project();
const settings = { folders: [root, other], searchRoots: [], excludes: [] };
const { scope } = scopeFor(settings);
for (const p of pendingList()) reject([p.id]);
const readme = path.join(root, "README.md");
// Off: a proposal waits.
assert.match(await maybeAutoApply(scope, proposeEdit(scope, readme, "First line.", "First!", "a")), /^status: proposed/);
reject("all");
assert.match(await runCommand(settings, "/files autoapply"), /Auto-apply is off/);
assert.match(await runCommand(settings, `/files autoapply ${root} soon`), /not a length of time/);
assert.match(await runCommand(settings, `/files autoapply ${os.homedir()}`), /outside the granted folders|too broad/);
assert.match(await runCommand(settings, `/files autoapply ${root} 30m`), /Auto-apply is on for .* until \d\d:\d\d, for up to 60 changes/);
const edited = await maybeAutoApply(scope, proposeEdit(scope, readme, "First line.", "First!", "a"));
assert.match(edited, /^status: applied\n#\d+ applied: edited/);
assert.match(edited, /Carry on with the task; do not ask the user to apply it/);
assert.match(fs.readFileSync(readme, "utf8"), /First!/);
assert.equal(pendingList().length, 0);
const created = await maybeAutoApply(scope, proposeNewFile(scope, path.join(root, "src", "new.ts"), "export {};\n", "b"));
assert.match(created, /^status: applied\n#\d+ applied: created/);
assert.match(await runCommand(settings, "/files status"), /auto-apply: \n .* until \d\d:\d\d, 58 changes left/);
assert.match(await runCommand(settings, "/files undo"), /Undid/);
assert.ok(!fs.existsSync(path.join(root, "src", "new.ts")), "undo still works");
// Another granted folder still asks.
assert.match(await maybeAutoApply(scope, proposeEdit(scope, path.join(other, "README.md"), "First line.", "First!", "c")), /^status: proposed/);
reject("all");
assert.match(await runCommand(settings, "/files autoapply off"), /Auto-apply is off/);
assert.match(await maybeAutoApply(scope, proposeEdit(scope, readme, "Second line.", "Second!", "e")), /^status: proposed/);
reject("all");
});
test("auto-apply ends after its limit of changes", async () => {
const root = project();
const settings = { folders: [root], searchRoots: [], excludes: [] };
const { scope } = scopeFor(settings);
await runCommand(settings, `/files autoapply ${root}`);
for (let i = 0; i < MAX_CHANGES; i++) {
assert.match(await maybeAutoApply(scope, proposeNewFile(scope, path.join(root, "many", `f${i}.txt`), `${i}\n`, "n")), /^status: applied/);
}
assert.match(await maybeAutoApply(scope, proposeNewFile(scope, path.join(root, "many", "last.txt"), "x\n", "n")), /^status: proposed/);
reject("all");
await runCommand(settings, "/files autoapply off");
});
test("the file name index skips hidden, tool and excluded folders, and resumes after a stop", async () => {
// Its own data folder, so builds started by other tests cannot touch its index.
process.env.SIMPLEFILES_DATA = tmp("sf-data-names-");
const root = tmp("sf-names-");
for (const d of ["Projects/simpleSearch/source", "Projects/notes", ".ssh", "AppData/Local/browser", "node_modules/x", "Private/taxes"]) {
fs.mkdirSync(path.join(root, d), { recursive: true });
}
fs.writeFileSync(path.join(root, "Projects/simpleSearch/README.md"), "");
fs.writeFileSync(path.join(root, "Projects/simpleSearch/source/plugin.ts"), "");
fs.writeFileSync(path.join(root, "Projects/notes/readme-draft.md"), "");
fs.writeFileSync(path.join(root, ".ssh/id_ed25519"), "");
fs.writeFileSync(path.join(root, "AppData/Local/browser/cookies.sqlite"), "");
fs.writeFileSync(path.join(root, "Private/taxes/2025.pdf"), "");
const names = new NameIndex([root], [path.join(root, "Private")]);
assert.equal(names.find("readme"), null, "no index yet");
await names.buildNow();
const all = names.find(path.basename(root), 100)!;
assert.ok(all.some((p) => p.endsWith("plugin.ts")));
assert.ok(!all.some((p) => /\.ssh|AppData|node_modules|taxes/.test(p)), "skipped folders never appear");
const readme = names.find("simplesearch readme")!;
assert.equal(path.basename(readme[0]), "README.md");
assert.ok(names.find(".md")!.every((p) => p.endsWith(".md")));
// Simulate a stop partway: a saved queue and some saved entries; the next start carries on.
const dir = path.join(process.env.SIMPLEFILES_DATA!, "names");
fs.writeFileSync(path.join(dir, "partial.txt"), path.join(root, "Projects") + path.sep + "\n");
fs.writeFileSync(path.join(dir, "build.json"), JSON.stringify({ roots: [root], excludes: [path.join(root, "Private")], queue: [path.join(root, "Projects")], count: 1, startedAt: Date.now() }));
const resumed = new NameIndex([root], [path.join(root, "Private")]);
resumed.refreshIfNeeded();
for (let i = 0; i < 100 && resumed.isBuilding(); i++) await new Promise((r) => setTimeout(r, 20));
assert.ok(resumed.find("plugin")!.some((p) => p.endsWith("plugin.ts")));
process.env.SIMPLEFILES_DATA = DATA;
});
test("the plugin's tools, without the development ones when they are off, and find_files marks what is not granted", async () => {
const root = project();
const values: Record<string, string> = { folders: "", searchRoots: root, excludes: "" };
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => values[k] }) } as never;
const tools = (await toolsProvider(ctl)) as unknown as Array<{ name: string; implementation: (a: unknown, c: unknown) => Promise<string> | string }>;
assert.deepEqual(tools.map((t) => t.name), ["find_files", "list_folder", "read_file", "search_in_files", "propose_edit", "propose_new_file", "copy_files", "read_form", "fill_form", "list_proposals"], "no granted folder holds a project, so run_script is not offered");
const off = (await toolsProvider({ getGlobalPluginConfig: () => ({ get: (k: string) => (k === "devTools" ? false : values[k]) }) } as never)) as unknown as Array<{ name: string }>;
assert.equal(off.length, 10);
const find = tools.find((t) => t.name === "find_files")!;
let out = await find.implementation({ query: "readme" }, {});
for (let i = 0; i < 100 && /index_building/.test(out); i++) {
await new Promise((r) => setTimeout(r, 30));
out = await find.implementation({ query: "readme" }, {});
}
assert.match(out, /README\.md \(not granted\)/);
assert.match(out, /\/files grant/);
assert.match(await tools.find((t) => t.name === "read_file")!.implementation({ path: path.join(root, "README.md") }, {}), /outside the granted folders/);
});
test("everything the tools show is marked as local, and its identifiers fingerprinted for the search plugin", async () => {
const root = project();
fs.writeFileSync(path.join(root, "notes.txt"), "Meet the Harrowgate supplier about the walnut cabinet order on Thursday. Order 88412397.\n");
const values: Record<string, string> = { folders: root, searchRoots: root, excludes: "" };
const ctl = { getGlobalPluginConfig: () => ({ get: (k: string) => values[k] }) } as never;
const tools = (await toolsProvider(ctl)) as unknown as Array<{ name: string; implementation: (a: unknown, c: unknown) => Promise<string> | string }>;
const before = Date.now();
const out = await tools.find((t) => t.name === "read_file")!.implementation({ path: path.join(root, "notes.txt") }, {});
assert.match(out, /walnut cabinet/);
assert.match(out, /\nprivacy: local, from simplefiles$/);
assert.ok(readShown().simplefiles >= before);
assert.ok(hasCopiedText("status of order 88412397"), "identifiers are recorded");
assert.ok(!hasCopiedText("harrowgate supplier about the walnut cabinet order"), "ordinary wording from files is not");
});
test("/files apply and reject understand numbers and 'all' written loosely, and refuse exceptions", async () => {
const root = project();
const settings = { folders: [root], searchRoots: [root], excludes: [] };
const { scope } = scopeFor(settings);
const file = path.join(root, "README.md");
const a = idOf(proposeEdit(scope, file, "First line.", "First!", "a"));
const b = idOf(proposeEdit(scope, file, "Second line.", "Second!", "b"));
const c = idOf(proposeEdit(scope, file, "Third line.", "Third!", "c"));
assert.match(await runCommand(settings, `/files apply all except #${b}`), /rather than an exception/);
assert.equal(pendingList().length, 3, "an exception applies nothing");
const out = await runCommand(settings, `/files apply #${a} and ${c} please`);
assert.match(out, new RegExp(`#${a} applied[\\s\\S]*#${c} applied`));
assert.deepEqual(pendingList().map((p) => p.id), [b]);
assert.match(await runCommand(settings, "/files reject everything"), /Dropped 1/);
const d = idOf(proposeEdit(scope, file, "# Demo", "# Demo!", "d"));
const e = idOf(proposeNewFile(scope, path.join(root, "new.md"), "x\n", "e"));
assert.match(await runCommand(settings, "/files apply all please"), new RegExp(`#${d} applied[\\s\\S]*#${e} applied`));
});