/* Built-in workspace tools: read ranges, list_dir, glob, grep, edit_file, * delete/move, todo_write, ask_user, previews and the sandbox. */ #define _POSIX_C_SOURCE 200809L #include "cst_agent.h" #include "cst_tools.h" #include #include #include #include #include #include #include "cst_test_util.h" static int nfail; #define EXPECT(cond, msg) \ do { \ if (!(cond)) { \ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \ nfail++; \ } \ } while (0) static char g_ws[256]; static char g_todo[512]; static void wfile(const char *rel, const char *s) { char p[512]; snprintf(p, sizeof(p), "%s/%s", g_ws, rel); FILE *fp = fopen(p, "wb"); if (fp) { fputs(s, fp); fclose(fp); } } static char *rfile(const char *rel) { char p[512]; snprintf(p, sizeof(p), "%s/%s", g_ws, rel); FILE *fp = fopen(p, "rb"); if (!fp) return NULL; static char buf[65536]; size_t n = fread(buf, 1, sizeof(buf) - 1, fp); buf[n] = 0; fclose(fp); return buf; } static int exists(const char *rel) { char p[512]; struct stat st; snprintf(p, sizeof(p), "%s/%s", g_ws, rel); return stat(p, &st) == 0; } static void set_todo(const char *text, void *ud) { (void)ud; snprintf(g_todo, sizeof(g_todo), "%s", text); } static int ask_yes(const char *q, const char *o, char *ans, size_t n, void *ud) { (void)q; (void)o; (void)ud; snprintf(ans, n, "yes please"); return 0; } /* name, then key/value pairs, NULL-terminated. */ static ctl_tool_call_t mk(const char *name, ...) { ctl_tool_call_t c; memset(&c, 0, sizeof(c)); snprintf(c.id, sizeof(c.id), "t"); snprintf(c.name, sizeof(c.name), "%s", name); va_list ap; va_start(ap, name); const char *k; while ((k = va_arg(ap, const char *)) != NULL) { const char *v = va_arg(ap, const char *); ctl_arg_t *a = &c.args[c.nargs++]; snprintf(a->key, sizeof(a->key), "%s", k); a->value = (char *)v; a->value_len = strlen(v); } va_end(ap); return c; } static int run(cst_tool_ctx_t *ctx, ctl_tool_call_t c, cst_tool_result_t *r) { memset(r, 0, sizeof(*r)); cst_tool_run(ctx, &c, r); return r->exit_code; } static void reset(cst_tool_result_t *r) { cst_tool_result_free(r); } int main(void) { if (test_tmpdir(g_ws, sizeof(g_ws), "cst-tools-test") != 0) { perror("tmpdir"); return 1; } char p[512]; snprintf(p, sizeof(p), "%s/src", g_ws); test_mkdir(p, 0755); snprintf(p, sizeof(p), "%s/src/deep", g_ws); test_mkdir(p, 0755); snprintf(p, sizeof(p), "%s/node_modules", g_ws); test_mkdir(p, 0755); snprintf(p, sizeof(p), "%s/build", g_ws); test_mkdir(p, 0755); wfile("src/main.c", "#include \nint main(void) {\n int x = 42;\n" " printf(\"%d\\n\", x);\n return 0;\n}\n"); wfile("src/deep/util.c", "int add(int a, int b) { return a + b; }\n" "int sub(int a, int b) { return a - b; }\n"); wfile("src/notes.txt", "TODO: x\n"); wfile("node_modules/junk.c", "int add(int a, int b);\n"); wfile("build/out.c", "int add_generated;\n"); wfile(".gitignore", "build/\n*.log\n"); wfile("run.log", "int add in log\n"); { FILE *fp = fopen(strcat(strcpy(p, g_ws), "/lines.txt"), "wb"); for (int i = 1; i <= 100; i++) fprintf(fp, "line %d\n", i); fclose(fp); } cst_tool_ctx_t ctx; memset(&ctx, 0, sizeof(ctx)); ctx.workspace_root = g_ws; ctx.set_todo = set_todo; cst_tool_result_t r; /* read_file: whole, slices, numbers */ EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", NULL), &r) == 0 && strcmp(r.out, "TODO: x\n") == 0, "read whole file unchanged"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "10", "limit", "3", "line_numbers", "true", NULL), &r) == 0, "read slice ok"); EXPECT(r.out && strstr(r.out, " 10|line 10\n 11|line 11\n 12|line 12\n"), "slice numbered lines"); EXPECT(r.out && strstr(r.out, "next offset=13"), "slice continuation hint"); EXPECT(r.out && !strstr(r.out, "line 13\n"), "slice limit respected"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "\"99\"", NULL), &r) == 0 && strstr(r.out, "line 100\n") && !strstr(r.out, "next offset"), "quoted offset (ctl style) reads to the end"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "abc", NULL), &r) == 1 && strstr(r.out, "integer"), "non-integer offset rejected"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "src", NULL), &r) == 1 && strstr(r.out, "list_dir"), "directory read suggests list_dir"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "../../etc/passwd", NULL), &r) == 1, "read outside workspace denied"); reset(&r); EXPECT(run(&ctx, mk("read_file", NULL), &r) == 1 && strstr(r.out, "needs argument \"path\""), "missing required arg"); reset(&r); /* compact read_file (default profile) */ ctx.compact = 1; EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", NULL), &r) == 0 && strcmp(r.out, "src/notes.txt · 1 line\n1|TODO: x\n") == 0, "compact read: header + numbers"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "10", "limit", "3", NULL), &r) == 0 && strstr(r.out, "lines.txt · lines 10-12 of 100\n10|line 10\n") == r.out && strstr(r.out, "… 88 more lines; continue with offset=13"), "compact slice header and continuation"); reset(&r); EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", "line_numbers", "false", NULL), &r) == 0 && strstr(r.out, "\nTODO: x\n") && !strstr(r.out, "1|"), "compact read without numbers"); reset(&r); { char big[5000]; memset(big, 'a', sizeof(big) - 2); big[sizeof(big) - 2] = '\n'; big[sizeof(big) - 1] = 0; wfile("long.txt", big); EXPECT(run(&ctx, mk("read_file", "path", "long.txt", NULL), &r) == 0 && strstr(r.out, "[line cut, +2998 bytes]") && strstr(r.out, "1 long line cut"), "compact read cuts long lines"); reset(&r); } wfile("empty.txt", ""); EXPECT(run(&ctx, mk("read_file", "path", "empty.txt", NULL), &r) == 0 && strstr(r.out, "empty file"), "compact read of an empty file"); reset(&r); ctx.compact = 0; /* list_dir */ EXPECT(run(&ctx, mk("list_dir", NULL), &r) == 0, "list_dir ok"); EXPECT(r.out && strstr(r.out, "src/") && strstr(r.out, " main.c"), "list_dir tree"); EXPECT(r.out && strstr(r.out, "node_modules/ (skipped)"), "node_modules shown but not walked"); EXPECT(r.out && !strstr(r.out, "junk.c"), "skipped dir contents hidden"); EXPECT(r.out && !strstr(r.out, "build/") && !strstr(r.out, "run.log"), ".gitignore respected"); reset(&r); EXPECT(run(&ctx, mk("list_dir", "path", "src", "depth", "1", NULL), &r) == 0 && strstr(r.out, "deep/") && !strstr(r.out, "util.c"), "list_dir depth"); EXPECT(r.out && strstr(r.out, "deep/ (1 entry)"), "list_dir counts entries at the depth limit"); reset(&r); /* glob */ EXPECT(run(&ctx, mk("glob", "pattern", "*.c", NULL), &r) == 0, "glob ok"); EXPECT(r.out && strstr(r.out, "src/main.c") && strstr(r.out, "src/deep/util.c"), "basename glob matches at any depth"); EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "out.c"), "glob skips node_modules and ignored dirs"); reset(&r); EXPECT(run(&ctx, mk("glob", "pattern", "src/*.c", NULL), &r) == 0 && strstr(r.out, "src/main.c") && !strstr(r.out, "util.c"), "single-segment glob"); reset(&r); EXPECT(run(&ctx, mk("glob", "pattern", "src/**/*.c", NULL), &r) == 0 && strstr(r.out, "src/main.c") && strstr(r.out, "util.c"), "** glob"); reset(&r); EXPECT(run(&ctx, mk("glob", "pattern", "*.rs", NULL), &r) == 0 && strstr(r.out, "no files match"), "glob no match"); reset(&r); /* grep */ EXPECT(run(&ctx, mk("grep", "pattern", "int add", NULL), &r) == 0, "grep ok"); EXPECT(r.out && strstr(r.out, "src/deep/util.c\n1:int add"), "grep hit grouped"); EXPECT(r.out && strncmp(r.out, "1 match in 1 file\n", 18) == 0, "grep summary first"); EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "run.log"), "grep skips node_modules and ignored files"); reset(&r); EXPECT(run(&ctx, mk("grep", "pattern", "x = \\d+", "glob", "*.c", NULL), &r) == 0 && strstr(r.out, "src/main.c\n3:"), "grep translates \\d"); reset(&r); EXPECT(run(&ctx, mk("grep", "pattern", "RETURN", "ignore_case", "true", "path", "src/main.c", "context", "1", NULL), &r) == 0 && strstr(r.out, "\n5: return 0;") && strstr(r.out, "\n4-") && strstr(r.out, "\n6-"), "grep single file, icase, context"); reset(&r); EXPECT(run(&ctx, mk("grep", "pattern", "(", NULL), &r) == 1 && strstr(r.out, "bad regex"), "grep bad regex"); reset(&r); /* edit_file */ EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string", "int x = 42;", "new_string", "int x = 7;", NULL), &r) == 0, "edit ok"); EXPECT(r.out && strstr(r.out, "- int x = 42;") && strstr(r.out, "+ int x = 7;"), "edit diff"); EXPECT(strstr(rfile("src/main.c"), "int x = 7;") != NULL, "edit written"); reset(&r); EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string", "nothing like this", "new_string", "y", NULL), &r) == 1 && strstr(r.out, "not found"), "edit not found"); reset(&r); EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string", "int main(void) {\n int x = 7;", "new_string", "y", NULL), &r) == 1 && strstr(r.out, "appears (whitespace aside) at line 2"), "edit not found points at the near line"); reset(&r); EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string", "int a, int b", "new_string", "long a, long b", NULL), &r) == 1 && strstr(r.out, "matches 2 places"), "edit ambiguous"); EXPECT(r.out && strstr(r.out, "(lines 1, 2)"), "edit ambiguous lines"); reset(&r); EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string", "int a, int b", "new_string", "long a, long b", "replace_all", "true", NULL), &r) == 0 && strstr(r.out, "2 replacements"), "edit replace_all"); reset(&r); wfile("crlf.txt", "one\r\ntwo\r\nthree\r\n"); EXPECT(run(&ctx, mk("edit_file", "path", "crlf.txt", "old_string", "one\ntwo\n", "new_string", "1\n2\n", NULL), &r) == 0 && strcmp(rfile("crlf.txt"), "1\r\n2\r\nthree\r\n") == 0, "edit keeps CRLF line endings"); reset(&r); /* previews never touch the disk */ { ctl_tool_call_t c = mk("edit_file", "path", "src/main.c", "old_string", "return 0;", "new_string", "return 1;", NULL); char *pv = NULL; EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv && strstr(pv, "+ return 1;"), "edit preview diff"); EXPECT(strstr(rfile("src/main.c"), "return 0;") != NULL, "preview did not write"); free(pv); c = mk("write_file", "path", "new/dir/x.txt", "content", "hi\n", NULL); EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv && strstr(pv, "create new/dir/x.txt") && strstr(pv, "+hi"), "write preview for a new file"); EXPECT(!exists("new"), "write preview did not mkdir"); free(pv); c = mk("apply_patch", "patch", "--- a/src/notes.txt\n+++ b/src/notes.txt\n@@ -1 +1 @@\n-TODO: x\n" "+DONE: x\n", NULL); EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv && strstr(pv, "+DONE: x"), "patch preview"); EXPECT(strcmp(rfile("src/notes.txt"), "TODO: x\n") == 0, "patch preview did not write"); free(pv); } /* move / delete */ EXPECT(run(&ctx, mk("move_file", "from", "src/notes.txt", "to", "docs/notes.md", NULL), &r) == 0 && exists("docs/notes.md") && !exists("src/notes.txt"), "move creates parent and renames"); reset(&r); wfile("other.txt", "o\n"); EXPECT(run(&ctx, mk("move_file", "from", "other.txt", "to", "docs/notes.md", NULL), &r) == 1 && strstr(r.out, "exists"), "move refuses to overwrite"); reset(&r); EXPECT(run(&ctx, mk("move_file", "from", "other.txt", "to", "../escape.txt", NULL), &r) == 1, "move outside workspace denied"); reset(&r); EXPECT(run(&ctx, mk("delete_file", "path", "other.txt", NULL), &r) == 0 && !exists("other.txt") && strstr(r.out, "-o"), "delete with diff"); reset(&r); EXPECT(run(&ctx, mk("delete_file", "path", "src", NULL), &r) == 1 && strstr(r.out, "directory"), "delete refuses directories"); reset(&r); /* todo_write / ask_user */ EXPECT(run(&ctx, mk("todo_write", "todos", "- [x] read code\n[~] write fix\nrun tests\n", NULL), &r) == 0 && strstr(r.out, "1/3 done"), "todo_write counts"); EXPECT(strcmp(g_todo, "[x] read code\n[~] write fix\n[ ] run tests\n") == 0, "todo normalized and handed to the agent"); reset(&r); EXPECT(run(&ctx, mk("ask_user", "question", "Proceed?", NULL), &r) == 1 && strstr(r.out, "no interactive user"), "ask_user headless"); reset(&r); ctx.ask = ask_yes; EXPECT(run(&ctx, mk("ask_user", "question", "Proceed?", NULL), &r) == 0 && strstr(r.out, "yes please"), "ask_user answered"); reset(&r); /* web_fetch refuses non-http schemes without touching the network */ EXPECT(run(&ctx, mk("web_fetch", "url", "file:///etc/passwd", NULL), &r) == 1, "web_fetch rejects file://"); reset(&r); /* undo targets */ { char paths[CST_TOOL_MAX_TARGETS][1024]; ctl_tool_call_t c = mk("move_file", "from", "a.txt", "to", "b.txt", NULL); EXPECT(cst_tool_targets(&c, paths, CST_TOOL_MAX_TARGETS) == 2 && strcmp(paths[0], "a.txt") == 0 && strcmp(paths[1], "b.txt") == 0, "move targets"); c = mk("apply_patch", "patch", "--- a/x.c\n+++ b/x.c\n@@ -1 +1 @@\n-a\n+b\n--- /dev/null\n+++ " "b/y.c\n@@ -0,0 +1 @@\n+y\n", NULL); EXPECT(cst_tool_targets(&c, paths, CST_TOOL_MAX_TARGETS) == 2 && strcmp(paths[0], "x.c") == 0 && strcmp(paths[1], "y.c") == 0, "patch targets"); } if (test_rmrf(g_ws) != 0) fprintf(stderr, "note: cleanup failed\n"); if (nfail) { fprintf(stderr, "test_tools_fs: %d failure(s)\n", nfail); return 1; } printf("test_tools_fs: OK\n"); return 0; }