commit 8c08fb626f0a7152afd1b8496836fb7e8c4af37d Author: trevon Date: Wed Aug 26 01:12:23 2026 +0000 push (solved) diff --git a/.forgejo/workflows/grade.yml b/.forgejo/workflows/grade.yml new file mode 100644 index 0000000..9e4d6c2 --- /dev/null +++ b/.forgejo/workflows/grade.yml @@ -0,0 +1,34 @@ +name: grade +on: [push] + +# Production wiring (gitea-runner on ghost, docker mode): +# - job container = gitea/runner-images (has docker CLI) + the host docker +# socket, so docker_grade.sh drives the host daemon exactly as designed +# - hidden tests mount from the runner host (/opt/grade-hidden) — never in +# this repo +# - GRADE_HMAC_SECRET mounts from /opt/grade/grade.env on the runner host +jobs: + grade: + runs-on: ubuntu-latest + container: + image: gitea/runner-images:ubuntu-22.04 + options: >- + -v /var/run/docker.sock:/var/run/docker.sock + -v /opt/grade/grade.env:/run/secrets/grade.env:ro + -v /opt/grade-hidden:/opt/grade-hidden:ro + steps: + - uses: actions/checkout@v4 + - name: Grade (blank fails, solved passes) + run: | + set -a; . /run/secrets/grade.env; set +a + docker run --rm --network=none \ + -v "$PWD":/work \ + -v /opt/grade-hidden/cs2060/record-manager:/hidden:ro \ + -e GRADE_HMAC_SECRET \ + grade-c:dev \ + python3 /opt/grade/grade_cli.py run \ + --student /work --hidden /hidden \ + --course CS2060 --assignment record-manager \ + --out /work/results.json + echo "--- signed result summary ---" + head -c 400 results.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd711e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +.cache/ +*.o +*.d diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f0bac10 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# shellcheck disable=SC2046,SC2035,SC2215,SC2285,SC2276,SC2068 +CC ?= cc +CFLAGS ?= -std=c17 -Wall -Wextra -Wpedantic -Werror -g +CPPFLAGS += -Iinclude -MMD -MP +SANFLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer + +SRC := $(wildcard src/*.c) +OBJ := $(SRC:src/%.c=build/%.o) +DEP := $(OBJ:.o=.d) +BIN := build/recman +ASAN := build/recman_asan + +all: $(BIN) + +$(BIN): $(OBJ) + $(CC) $(CFLAGS) $^ -o $@ + +$(ASAN): $(SRC) + $(CC) $(CFLAGS) $(SANFLAGS) $(CPPFLAGS) $^ -o $@ + +build/%.o: src/%.c | build + $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@ + +build: + mkdir -p build + +asan: $(ASAN) + +test: $(BIN) $(ASAN) + ASAN_OPTIONS=detect_leaks=0 sh tests/smoke.sh $(BIN) + ASAN_OPTIONS=detect_leaks=0 sh tests/smoke.sh $(ASAN) + $(CC) $(CFLAGS) $(SANFLAGS) $(CPPFLAGS) -Itests tests/unit.c $(filter-out src/main.c,$(SRC)) -o build/unit_asan + ASAN_OPTIONS=detect_leaks=0 ./build/unit_asan + +clean: + rm -rf build + +-include $(DEP) + +.PHONY: all asan test clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7fd3a4 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Record Manager — Instructor Reference Solution (M1–M5) + +**PRIVATE — never ships to students.** This is the reference implementation for +the Record Manager cumulative project (`assignments/projects/`), milestone by +milestone, plus the graded-interface contract and grading notes. + +## Layout + +``` +include/record.h M1 record_t + record_new/free + status_t +include/buf.h M2 dynamic string buffer (+ readline) +include/list.h M3 opaque list_t + append/find/remove (+ len/get/sort helpers) +include/store.h M4 store_save / store_load (text file, '|' delimiter) +src/record.c M1 deep-copy record lifecycle +src/buf.c M2 growable buffer: append/appendc/cstr/reset/free/readline +src/list.c M3 singly linked list; remove frees node + record +src/store.c M4 one record per line; load appends + reports count +src/main.c M5 menu (add/list/search/delete/save/load/help/quit) +Makefile builds recman + recman_asan; `make test` runs unit + smoke +tests/unit.c direct tests of buf/list/store (links library sources) +tests/smoke.sh end-to-end CLI session +``` + +## Build + verify + +```bash +make # recman +make asan # recman_asan (ASan+UBSan) +make test # unit + smoke, both under ASan/UBSan +``` + +Reference must be `-std=c17 -Wall -Wextra -Wpedantic -Werror` clean and report +`0 bytes lost` under Valgrind. + +## Graded interface (what hidden tests touch) + +`record_new`, `record_free`, `list_new`, `list_free`, `list_append`, `list_find`, +`list_remove`, `store_save`, `store_load` — exact signatures in +`assignments/projects/README.md`. This reference adds three non-graded helpers +(`list_len`, `list_get`, `list_sort`) that the menu and store layer need; they +are extra, not substitutes. + +### Contract decisions (documented for graders) + +- **`list_remove`**: returns `0` on success, `STATUS_ERR_NOTFOUND` when no record + matches, `STATUS_ERR_ARG` on NULL args. Frees the matching record + node. +- **`store_load`**: returns the **number of records read** (≥ 0) on success; + negative `status_t` on error. "0 on success" from the interface table is + satisfied when the file is empty/blank. It **appends** into the existing list. +- **Delimiter `|`**: none of the three flavors' fields (email/phone, assignment/ + score, category/amount) can contain `|`; `name` may contain spaces — the + delimiter is safe across all flavors. +- **Empty fields**: saved as empty strings; loaded back as empty strings + (never NULL from `store_load`). + +## Milestone-by-milestone notes + +### M2 — dynamic string buffer (week 7) + +Hidden tests likely: appends beyond initial capacity, `buf_cstr` NUL-termination +after every append, reset-then-reuse, `buf_readline` on a long line (>256 chars), +EOF/empty-line behavior. Sticky-OOM flag keeps the buffer usable after a failed +realloc. **Common mistake:** forgetting the `+1` for the NUL in `buf_grow`, or +realloc-ing to a shrinking capacity. + +### M3 — linked list (week 11) + +Hidden tests likely: append to empty list, find across many nodes, remove head / +remove middle / remove missing, `list_free` of a 3-node list under Valgrind +(0 lost), NULL-arg safety. **Common mistakes:** freeing the record and then +walking `next` (order matters — save `next` first); `list_remove` freeing the +node but not the record (double-free later); storing `record_t *` by value +instead of pointer. + +### M4 — text file save/load (week 13) + +Hidden tests likely: save→load round-trip preserves order + all three fields; +load into a non-empty list appends; file with blank lines; missing file returns +error, not crash; fields containing spaces survive (the `|` delimiter). +**Common mistakes:** assuming `fgets` succeeds; writing with `fprintf` and never +checking `ferror`; not stripping `\n`; `strtok` mangles empty fields (use +`strchr` splitting instead — empty field1/field2 must survive). + +### M5 — refactor + search/sort + hardening (week 15) + +Hidden tests likely: multi-file build with one `make`; case-insensitive +substring `search` across **all three** fields; `sort` alphabetical by name; +ASan/UBSan-clean; Valgrind 0 lost. **Common mistakes:** search matching only +`name`; sort comparing `record_t*` pointers instead of `->name`; `-Werror` +violations from unused includes after the refactor. + +## Grading sanity checks + +1. `make` from a clean tree (no build/ artifacts committed). +2. `make asan && ./build/recman_asan` → add 3 records → delete middle → save → + quit → reload → `list` shows the surviving two in order. +3. `valgrind --leak-check=full ./build/recman` with the same session → `0 bytes + lost` (the menu leaks nothing). +4. Feed the `tests/` fixtures: long input line (buf), 10k-record list (list), + save/load with spaces and empties (store). diff --git a/compile_commands.json b/compile_commands.json new file mode 100644 index 0000000..c43493e --- /dev/null +++ b/compile_commands.json @@ -0,0 +1,27 @@ +[ + { + "directory": "/home/trevon/Documents/work/coursework/instructor/CS2060/record-manager", + "command": "cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -Iinclude -MMD -MP -c src/main.c -o build/main.o", + "file": "src/main.c" + }, + { + "directory": "/home/trevon/Documents/work/coursework/instructor/CS2060/record-manager", + "command": "cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -Iinclude -MMD -MP -c src/record.c -o build/record.o", + "file": "src/record.c" + }, + { + "directory": "/home/trevon/Documents/work/coursework/instructor/CS2060/record-manager", + "command": "cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -Iinclude -MMD -MP -c src/buf.c -o build/buf.o", + "file": "src/buf.c" + }, + { + "directory": "/home/trevon/Documents/work/coursework/instructor/CS2060/record-manager", + "command": "cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -Iinclude -MMD -MP -c src/list.c -o build/list.o", + "file": "src/list.c" + }, + { + "directory": "/home/trevon/Documents/work/coursework/instructor/CS2060/record-manager", + "command": "cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -Iinclude -MMD -MP -c src/store.c -o build/store.o", + "file": "src/store.c" + } +] diff --git a/hidden/__pycache__/test_hidden.cpython-314-pytest-9.1.1.pyc b/hidden/__pycache__/test_hidden.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..a257e42 Binary files /dev/null and b/hidden/__pycache__/test_hidden.cpython-314-pytest-9.1.1.pyc differ diff --git a/hidden/test_hidden.py b/hidden/test_hidden.py new file mode 100644 index 0000000..9b7e0c8 --- /dev/null +++ b/hidden/test_hidden.py @@ -0,0 +1,44 @@ +"""Hidden interface test for the Record Manager (blank must fail, solved must pass). + +Runs with pytest's cwd = the assembled submission (/work/submission). +Blank (M1 starter) fails because `save`/`load` are unimplemented ("not yet"); +the full reference implements the store round-trip. +""" + +import pathlib +import subprocess + + +def _root() -> pathlib.Path: + return pathlib.Path.cwd() + + +def _run(cmd, timeout=180, **kw): + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kw) + + +def test_record_manager_interface(): + root = _root() + + build = _run(["make", "-C", str(root), "-j2"]) + assert build.returncode == 0, "BUILD FAILED:\n" + build.stdout + build.stderr + recman = root / "build" / "recman" + assert recman.exists(), "build/recman missing after make" + + # add two records, save to a file + session1 = ( + "add\nAda Lovelace\nada@x\n555-0100\n" + "add\nGrace Hopper\ngrace@x\n444-0200\n" + "save\n/tmp/rm_hidden.txt\nquit\n" + ) + r = _run([str(recman)], input=session1) + assert "added: Ada Lovelace" in r.stdout, "add failed:\n" + r.stdout + assert "added: Grace Hopper" in r.stdout, "add failed:\n" + r.stdout + assert "saved" in r.stdout, "save did not persist (blank starter prints 'not yet'):\n" + r.stdout + + # load back in a fresh session and confirm the round-trip + session2 = "load\n/tmp/rm_hidden.txt\nlist\nquit\n" + r2 = _run([str(recman)], input=session2) + assert "Ada Lovelace" in r2.stdout and "Grace Hopper" in r2.stdout, ( + "save/load round-trip failed:\n" + r2.stdout + ) diff --git a/include/buf.h b/include/buf.h new file mode 100644 index 0000000..2aa0777 --- /dev/null +++ b/include/buf.h @@ -0,0 +1,28 @@ +#ifndef BUF_H +#define BUF_H + +#include +#include + +/* + * Growable byte buffer (Milestone 2). Holds arbitrary bytes; buf_cstr() always + * returns a NUL-terminated view so the buffer can be used as a string. + * Opaque: callers only see buf_t *. + */ +typedef struct buf buf_t; + +buf_t *buf_new(void); /* empty buffer; NULL on OOM */ +int buf_append(buf_t *b, const char *s, size_t n); /* append n bytes; 0 ok, -1 OOM */ +int buf_appendc(buf_t *b, char c); /* append one byte; 0 ok, -1 OOM */ +const char *buf_cstr(const buf_t *b); /* NUL-terminated view; "" if empty */ +size_t buf_len(const buf_t *b); /* bytes currently held */ +void buf_reset(buf_t *b); /* drop contents, keep allocation */ +void buf_free(buf_t *b); /* free buffer + storage */ + +/* + * Read one full line (including the trailing newline) from f into b. + * Returns the line length (>= 0), -1 at EOF with nothing read, or -2 on error. + */ +long buf_readline(buf_t *b, FILE *f); + +#endif /* BUF_H */ diff --git a/include/list.h b/include/list.h new file mode 100644 index 0000000..2c04e60 --- /dev/null +++ b/include/list.h @@ -0,0 +1,22 @@ +#ifndef LIST_H +#define LIST_H + +#include "record.h" + +/* + * Singly linked list of records (Milestone 3). Opaque handle: callers can + * never touch node fields — they go through the functions below. + */ +typedef struct list list_t; + +list_t *list_new(void); /* empty list; NULL on OOM */ +void list_free(list_t *l); /* frees every node + record */ +int list_append(list_t *l, record_t *r); /* add to end; 0 ok, -1 OOM */ +record_t *list_find(list_t *l, const char *name);/* exact name match; NULL if none */ +int list_remove(list_t *l, const char *name); /* remove + free match; 0 ok, + STATUS_ERR_NOTFOUND if absent */ +size_t list_len(const list_t *l); /* number of records */ +record_t *list_get(const list_t *l, size_t i); /* i-th record; NULL if OOB */ +int list_sort(list_t *l); /* alphabetical by name (M5) */ + +#endif /* LIST_H */ diff --git a/include/record.h b/include/record.h new file mode 100644 index 0000000..6aff6ad --- /dev/null +++ b/include/record.h @@ -0,0 +1,40 @@ +#ifndef RECORD_H +#define RECORD_H + +#include + +/* + * Uniform status codes. Every fallible function returns status_t; check it at + * the call site. STATUS_OK (0) always means success. + */ +typedef enum { + STATUS_OK = 0, + STATUS_ERR_ARG, /* bad input / usage */ + STATUS_ERR_NOMEM, /* allocation failed */ + STATUS_ERR_IO, /* file or stream error */ + STATUS_ERR_NOTFOUND, /* item not present */ +} status_t; + +/* + * One record. Pick a flavor and map the three fields: + * contacts -> name, email, phone + * gradebook -> student, assignment, score + * expenses -> date, category, amount + */ +typedef struct record { + char *name; + char *field1; + char *field2; +} record_t; + +/* Heap-allocate a record with deep copies of the strings (NULL/empty allowed). + Returns NULL on allocation failure. */ +record_t *record_new(const char *name, const char *field1, const char *field2); + +/* Free a record and its owned strings. */ +void record_free(record_t *rec); + +/* Human-readable name for a status code (never NULL). */ +const char *status_str(status_t s); + +#endif /* RECORD_H */ diff --git a/include/store.h b/include/store.h new file mode 100644 index 0000000..0ee2d04 --- /dev/null +++ b/include/store.h @@ -0,0 +1,22 @@ +#ifndef STORE_H +#define STORE_H + +#include "list.h" + +/* + * Text-file persistence (Milestone 4). One record per line, '|' delimiter: + * name|field1|field2 + * The delimiter is safe for all three flavors (email/phone, assignment/score, + * category/amount never contain '|'); names may contain spaces. + */ + +/* Write the whole list to path. Returns 0 on success, a negative status_t + (STATUS_ERR_IO / STATUS_ERR_ARG) on failure. */ +int store_save(const list_t *l, const char *path); + +/* Read records from path, APPENDING into *l. Returns the number of records + read (>= 0) on success, or a negative status_t on failure. Blank lines are + skipped. Fields are never NULL after a successful load. */ +int store_load(list_t **l, const char *path); + +#endif /* STORE_H */ diff --git a/src/buf.c b/src/buf.c new file mode 100644 index 0000000..e210219 --- /dev/null +++ b/src/buf.c @@ -0,0 +1,101 @@ +#include "buf.h" + +#include +#include + +struct buf { + char *data; + size_t len; /* bytes used, excluding the trailing NUL */ + size_t cap; /* allocated capacity */ + int oom; /* sticky OOM flag: after one failed grow, refuse further ops */ +}; + +buf_t *buf_new(void) { + buf_t *b = malloc(sizeof(*b)); + if (b == NULL) + return NULL; + b->data = NULL; + b->len = 0; + b->cap = 0; + b->oom = 0; + return b; +} + +/* Ensure capacity for `need` bytes (+1 for the NUL). Returns 0 or -1. */ +static int buf_grow(buf_t *b, size_t need) { + size_t ncap; + char *nd; + + if (b->oom) + return -1; + if (need <= b->cap) + return 0; + ncap = b->cap ? b->cap : 16; + while (ncap < need) + ncap *= 2; + nd = realloc(b->data, ncap); + if (nd == NULL) { + b->oom = 1; + return -1; + } + b->data = nd; + b->cap = ncap; + return 0; +} + +int buf_append(buf_t *b, const char *s, size_t n) { + if (b == NULL || (s == NULL && n != 0)) + return -1; + if (n == 0) + return 0; + if (buf_grow(b, b->len + n + 1) != 0) + return -1; + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; + return 0; +} + +int buf_appendc(buf_t *b, char c) { return buf_append(b, &c, 1); } + +const char *buf_cstr(const buf_t *b) { + if (b == NULL || b->oom) + return NULL; + return b->data != NULL ? b->data : ""; +} + +size_t buf_len(const buf_t *b) { return b != NULL ? b->len : 0; } + +void buf_reset(buf_t *b) { + if (b != NULL) { + b->len = 0; + if (b->data != NULL) + b->data[0] = '\0'; /* cstr() now reads as empty */ + } +} + +void buf_free(buf_t *b) { + if (b == NULL) + return; + free(b->data); + free(b); +} + +long buf_readline(buf_t *b, FILE *f) { + int c; + long n = 0; + + if (b == NULL || f == NULL) + return -2; + buf_reset(b); + while ((c = fgetc(f)) != EOF) { + if (buf_appendc(b, (char)c) != 0) + return -2; + n++; + if (c == '\n') + break; + } + if (n == 0 && c == EOF) + return -1; /* EOF with nothing read */ + return n; +} diff --git a/src/list.c b/src/list.c new file mode 100644 index 0000000..1108649 --- /dev/null +++ b/src/list.c @@ -0,0 +1,113 @@ +#include "list.h" + +#include +#include + +typedef struct node { + record_t *rec; + struct node *next; +} node_t; + +struct list { + node_t *head; + size_t len; +}; + +list_t *list_new(void) { return calloc(1, sizeof(list_t)); } + +void list_free(list_t *l) { + node_t *n; + node_t *next; + + if (l == NULL) + return; + for (n = l->head; n != NULL; n = next) { + next = n->next; + record_free(n->rec); + free(n); + } + free(l); +} + +int list_append(list_t *l, record_t *r) { + node_t **tail; + node_t *nn; + + if (l == NULL || r == NULL) + return -1; + nn = malloc(sizeof(*nn)); + if (nn == NULL) + return -1; + nn->rec = r; + nn->next = NULL; + for (tail = &l->head; *tail != NULL; tail = &(*tail)->next) + ; + *tail = nn; + l->len++; + return 0; +} + +record_t *list_find(list_t *l, const char *name) { + node_t *n; + + if (l == NULL || name == NULL) + return NULL; + for (n = l->head; n != NULL; n = n->next) + if (n->rec->name != NULL && strcmp(n->rec->name, name) == 0) + return n->rec; + return NULL; +} + +int list_remove(list_t *l, const char *name) { + node_t **p; + node_t *victim; + + if (l == NULL || name == NULL) + return STATUS_ERR_ARG; + for (p = &l->head; *p != NULL; p = &(*p)->next) { + if ((*p)->rec->name != NULL && strcmp((*p)->rec->name, name) == 0) { + victim = *p; + *p = victim->next; + record_free(victim->rec); + free(victim); + l->len--; + return STATUS_OK; + } + } + return STATUS_ERR_NOTFOUND; +} + +size_t list_len(const list_t *l) { return l != NULL ? l->len : 0; } + +record_t *list_get(const list_t *l, size_t i) { + node_t *n; + + if (l == NULL || i >= l->len) + return NULL; + for (n = l->head; i > 0; i--) + n = n->next; + return n->rec; +} + +/* Selection sort over record pointers; nodes stay put, records get re-pointed. + Case-sensitive alphabetical order by name (per milestone spec). */ +int list_sort(list_t *l) { + node_t *i; + node_t *j; + node_t *min; + + if (l == NULL) + return -1; + for (i = l->head; i != NULL && i->next != NULL; i = i->next) { + min = i; + for (j = i->next; j != NULL; j = j->next) + if (strcmp(j->rec->name, min->rec->name) < 0) + min = j; + if (min != i) { + record_t *tmp = i->rec; + i->rec = min->rec; + min->rec = tmp; + } + } + return 0; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..05a680a --- /dev/null +++ b/src/main.c @@ -0,0 +1,262 @@ +/* + * Record Manager — menu driver (Milestone 5). + * Commands: add, list, search, delete, save, load, help, quit. + * Input lines are read with the dynamic buffer (no fixed-size arrays). + */ +#define _POSIX_C_SOURCE 200809L /* strdup under -std=c17 */ +#include + +/* compile_commands.json in this directory carries -Iinclude for clangd. */ +#include +#include +#include + +#include "buf.h" +#include "list.h" +#include "store.h" + +static list_t *g_list; + +static void prompt(void) { fputs("recman> ", stdout); fflush(stdout); } + +static void trim_newline(char *s) { + size_t n = strlen(s); + if (n > 0 && s[n - 1] == '\n') + s[n - 1] = '\0'; +} + +static char *lowerdup(const char *s) { + char *p = strdup(s); + char *q; + if (p == NULL) + return NULL; + for (q = p; *q != '\0'; q++) + *q = (char)tolower((unsigned char)*q); + return p; +} + +/* Case-insensitive substring match against all three fields (M5). */ +static int record_matches(const record_t *r, const char *needle_low) { + const char *fields[3]; + size_t i; + char *hay_low; + + fields[0] = r->name; + fields[1] = r->field1; + fields[2] = r->field2; + for (i = 0; i < 3; i++) { + if (fields[i] == NULL) + continue; + hay_low = lowerdup(fields[i]); + if (hay_low == NULL) + return 0; + if (strstr(hay_low, needle_low) != NULL) { + free(hay_low); + return 1; + } + free(hay_low); + } + return 0; +} + +static void cmd_add(buf_t *in) { + record_t *r; + char *name; + char *f1; + char *f2; + char *s; + + fputs("name: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + s = (char *)buf_cstr(in); + trim_newline(s); + name = strdup(s); + + fputs("field1: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + s = (char *)buf_cstr(in); + trim_newline(s); + f1 = strdup(s); + + fputs("field2: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + s = (char *)buf_cstr(in); + trim_newline(s); + f2 = strdup(s); + + r = record_new(name, f1, f2); + free(name); + free(f1); + free(f2); + if (r == NULL || list_append(g_list, r) != 0) { + record_free(r); + puts("error: out of memory"); + return; + } + printf("added: %s\n", r->name); +} + +static void cmd_list(void) { + size_t i; + size_t n = list_len(g_list); + record_t *r; + + if (n == 0) { + puts("(empty)"); + return; + } + for (i = 0; i < n; i++) { + r = list_get(g_list, i); + printf("%zu. %s | %s | %s\n", i + 1, r->name ? r->name : "", + r->field1 ? r->field1 : "", r->field2 ? r->field2 : ""); + } +} + +static void cmd_search(buf_t *in) { + size_t i; + size_t n = list_len(g_list); + record_t *r; + char *needle; + char *needle_low; + int found = 0; + + fputs("search: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + needle = (char *)buf_cstr(in); + trim_newline(needle); + needle_low = lowerdup(needle); + if (needle_low == NULL) { + puts("error: out of memory"); + return; + } + for (i = 0; i < n; i++) { + r = list_get(g_list, i); + if (record_matches(r, needle_low)) { + printf("%zu. %s | %s | %s\n", i + 1, r->name ? r->name : "", + r->field1 ? r->field1 : "", r->field2 ? r->field2 : ""); + found = 1; + } + } + free(needle_low); + if (!found) + puts("(no matches)"); +} + +static void cmd_delete(buf_t *in) { + char *name; + + fputs("delete name: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + name = (char *)buf_cstr(in); + trim_newline(name); + if (list_remove(g_list, name) == STATUS_OK) + printf("deleted: %s\n", name); + else + printf("not found: %s\n", name); +} + +static void cmd_save(buf_t *in) { + char *path; + + fputs("save to: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + path = (char *)buf_cstr(in); + trim_newline(path); + if (store_save(g_list, path) == STATUS_OK) + printf("saved %zu records\n", list_len(g_list)); + else + puts("error: could not save"); +} + +static void cmd_load(buf_t *in) { + char *path; + int n; + + fputs("load from: ", stdout); + fflush(stdout); + if (buf_readline(in, stdin) < 0) + return; + path = (char *)buf_cstr(in); + trim_newline(path); + n = store_load(&g_list, path); + if (n >= 0) + printf("loaded %d records\n", n); + else + puts("error: could not load"); +} + +static void cmd_sort(void) { + if (list_sort(g_list) == STATUS_OK) + puts("sorted by name"); +} + +static void cmd_help(void) { + puts("add add a record (name, field1, field2)"); + puts("list show all records"); + puts("search case-insensitive substring across all fields"); + puts("delete remove a record by exact name"); + puts("sort sort records alphabetically by name"); + puts("save persist records to a text file"); + puts("load load records from a text file (appends)"); + puts("help this help"); + puts("quit exit"); +} + +int main(void) { + buf_t *in; + char *line; + int quit = 0; + + g_list = list_new(); + in = buf_new(); + if (g_list == NULL || in == NULL) { + fputs("error: out of memory\n", stderr); + return 1; + } + + puts("Record Manager — type 'help' for commands."); + while (!quit) { + prompt(); + if (buf_readline(in, stdin) < 0) + break; /* EOF */ + line = (char *)buf_cstr(in); + trim_newline(line); + + if (strcmp(line, "add") == 0) + cmd_add(in); + else if (strcmp(line, "list") == 0) + cmd_list(); + else if (strcmp(line, "search") == 0) + cmd_search(in); + else if (strcmp(line, "delete") == 0) + cmd_delete(in); + else if (strcmp(line, "sort") == 0) + cmd_sort(); + else if (strcmp(line, "save") == 0) + cmd_save(in); + else if (strcmp(line, "load") == 0) + cmd_load(in); + else if (strcmp(line, "help") == 0) + cmd_help(); + else if (strcmp(line, "quit") == 0) + quit = 1; + else if (line[0] != '\0') + printf("unknown command: %s\n", line); + } + + list_free(g_list); + buf_free(in); + return 0; +} diff --git a/src/record.c b/src/record.c new file mode 100644 index 0000000..8db70ca --- /dev/null +++ b/src/record.c @@ -0,0 +1,60 @@ +#include "record.h" + +#include +#include + +static char *dup_str(const char *s) { + size_t n; + char *p; + + if (s == NULL) + return NULL; + n = strlen(s) + 1; + p = malloc(n); + if (p != NULL) + memcpy(p, s, n); + return p; +} + +record_t *record_new(const char *name, const char *field1, const char *field2) { + record_t *r = malloc(sizeof(*r)); + if (r == NULL) + return NULL; + + r->name = dup_str(name); + r->field1 = dup_str(field1); + r->field2 = dup_str(field2); + + if ((name != NULL && r->name == NULL) || (field1 != NULL && r->field1 == NULL) || + (field2 != NULL && r->field2 == NULL)) { + record_free(r); + return NULL; + } + return r; +} + +void record_free(record_t *rec) { + if (rec == NULL) + return; + free(rec->name); + free(rec->field1); + free(rec->field2); + free(rec); +} + +const char *status_str(status_t s) { + switch (s) { + case STATUS_OK: + return "ok"; + case STATUS_ERR_ARG: + return "invalid argument"; + case STATUS_ERR_NOMEM: + return "out of memory"; + case STATUS_ERR_IO: + return "I/O error"; + case STATUS_ERR_NOTFOUND: + return "not found"; + default: + return "unknown error"; + } +} diff --git a/src/store.c b/src/store.c new file mode 100644 index 0000000..be95fff --- /dev/null +++ b/src/store.c @@ -0,0 +1,94 @@ +#include "store.h" + +#include +#include +#include + +#include "buf.h" + +#define DELIM '|' + +int store_save(const list_t *l, const char *path) { + FILE *f; + size_t i; + size_t n; + record_t *r; + + if (l == NULL || path == NULL) + return -STATUS_ERR_ARG; + f = fopen(path, "w"); + if (f == NULL) + return -STATUS_ERR_IO; + n = list_len(l); + for (i = 0; i < n; i++) { + r = list_get(l, i); + fprintf(f, "%s%c%s%c%s\n", r->name != NULL ? r->name : "", DELIM, + r->field1 != NULL ? r->field1 : "", DELIM, + r->field2 != NULL ? r->field2 : ""); + if (ferror(f)) { + fclose(f); + return -STATUS_ERR_IO; + } + } + if (fclose(f) != 0) + return -STATUS_ERR_IO; + return STATUS_OK; +} + +int store_load(list_t **lp, const char *path) { + FILE *f; + buf_t *b; + long n; + int count = 0; + char *line; + char *f1; + char *f2; + char *rest; + size_t len; + record_t *r; + + if (lp == NULL || *lp == NULL || path == NULL) + return -STATUS_ERR_ARG; + f = fopen(path, "r"); + if (f == NULL) + return -STATUS_ERR_IO; + b = buf_new(); + if (b == NULL) { + fclose(f); + return -STATUS_ERR_NOMEM; + } + while ((n = buf_readline(b, f)) >= 0) { + line = (char *)buf_cstr(b); + len = buf_len(b); + if (len > 0 && line[len - 1] == '\n') + line[len - 1] = '\0'; + if (line[0] == '\0') + continue; /* skip blank lines */ + + f1 = strchr(line, DELIM); + if (f1 != NULL) + *f1++ = '\0'; + f2 = f1 != NULL ? strchr(f1, DELIM) : NULL; + if (f2 != NULL) + *f2++ = '\0'; + rest = f2 != NULL ? f2 : ""; + + r = record_new(line, f1 != NULL ? f1 : "", rest); + if (r == NULL) { + buf_free(b); + fclose(f); + return -STATUS_ERR_NOMEM; + } + if (list_append(*lp, r) != 0) { + record_free(r); + buf_free(b); + fclose(f); + return -STATUS_ERR_NOMEM; + } + count++; + } + buf_free(b); + if (fclose(f) != 0) + return -STATUS_ERR_IO; + return count; /* records read (>= 0) */ +} diff --git a/tests/public/__pycache__/test_public.cpython-314-pytest-9.0.3.pyc b/tests/public/__pycache__/test_public.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..f650fd8 Binary files /dev/null and b/tests/public/__pycache__/test_public.cpython-314-pytest-9.0.3.pyc differ diff --git a/tests/public/__pycache__/test_public.cpython-314-pytest-9.1.1.pyc b/tests/public/__pycache__/test_public.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..319e2a2 Binary files /dev/null and b/tests/public/__pycache__/test_public.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/public/test_public.py b/tests/public/test_public.py new file mode 100644 index 0000000..025dc07 --- /dev/null +++ b/tests/public/test_public.py @@ -0,0 +1,32 @@ +"""Public tests for the Record Manager (M1: build + menu basics). + +These ship to students and run under the grade harness (pytest, cwd = the +submission). M1 features only: add + list must work after `make` (delete/save/load come in +later milestones — the hidden suite gates those). +""" + +import pathlib +import subprocess + + +def _run(cmd, timeout=180, **kw): + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kw) + + +def test_m1_menu_basics(): + root = pathlib.Path.cwd() + build = _run(["make", "-C", str(root), "-j2"]) + assert build.returncode == 0, "BUILD FAILED:\n" + build.stdout + build.stderr + recman = root / "build" / "recman" + assert recman.exists(), "build/recman missing after make" + + session = ( + "add\nAda Lovelace\nada@x\n555-0100\n" + "add\nGrace Hopper\ngrace@x\n444-0200\n" + "list\n" + "quit\n" + ) + r = _run([str(recman)], input=session) + assert "added: Ada Lovelace" in r.stdout, "add failed:\n" + r.stdout + assert "added: Grace Hopper" in r.stdout, "add failed:\n" + r.stdout + assert "Grace Hopper" in r.stdout, "list did not show the records:\n" + r.stdout diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100644 index 0000000..b75a512 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# End-to-end CLI session for the Record Manager reference. +# Usage: sh tests/smoke.sh +set -eu + +BIN="${1:?usage: smoke.sh }" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +run_session() { + "$BIN" < +#include +#include + +#include "buf.h" +#include "list.h" +#include "store.h" + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + return 1; \ + } \ + } while (0) + +static int test_buf(void) { + buf_t *b = buf_new(); + CHECK(b != NULL); + CHECK(strcmp(buf_cstr(b), "") == 0); + CHECK(buf_append(b, "hello", 5) == 0); + CHECK(buf_append(b, " world", 6) == 0); + CHECK(buf_len(b) == 11); + CHECK(strcmp(buf_cstr(b), "hello world") == 0); + buf_reset(b); + CHECK(buf_len(b) == 0); + CHECK(strcmp(buf_cstr(b), "") == 0); + buf_free(b); + return 0; +} + +static int test_list(void) { + list_t *l = list_new(); + record_t *r; + CHECK(l != NULL); + CHECK(list_len(l) == 0); + CHECK(list_append(l, record_new("zed", "z@x", "1")) == 0); + CHECK(list_append(l, record_new("amy", "a@x", "2")) == 0); + CHECK(list_append(l, record_new("bob", "b@x", "3")) == 0); + CHECK(list_len(l) == 3); + r = list_find(l, "amy"); + CHECK(r != NULL && strcmp(r->field1, "a@x") == 0); + CHECK(list_find(l, "nope") == NULL); + CHECK(list_remove(l, "amy") == STATUS_OK); + CHECK(list_len(l) == 2); + CHECK(list_find(l, "amy") == NULL); + CHECK(list_remove(l, "amy") == STATUS_ERR_NOTFOUND); + CHECK(list_remove(l, NULL) == STATUS_ERR_ARG); + list_sort(l); + CHECK(strcmp(list_get(l, 0)->name, "bob") == 0); /* after sort: bob, zed */ + CHECK(strcmp(list_get(l, 1)->name, "zed") == 0); + list_free(l); + return 0; +} + +static int test_store(void) { + list_t *l = list_new(); + list_t *l2 = list_new(); + int n; + CHECK(l != NULL && l2 != NULL); + CHECK(list_append(l, record_new("Ada Lovelace", "ada@x", "555-0100")) == 0); + CHECK(list_append(l, record_new("grace", "", "third field with spaces")) == 0); + CHECK(store_save(l, "/tmp/recman_unit_store.txt") == STATUS_OK); + n = store_load(&l2, "/tmp/recman_unit_store.txt"); + CHECK(n == 2); + CHECK(list_len(l2) == 2); + CHECK(strcmp(list_get(l2, 0)->name, "Ada Lovelace") == 0); + CHECK(strcmp(list_get(l2, 0)->field2, "555-0100") == 0); + CHECK(strcmp(list_get(l2, 1)->field1, "") == 0); + CHECK(strcmp(list_get(l2, 1)->field2, "third field with spaces") == 0); + /* load appends into a non-empty list */ + n = store_load(&l2, "/tmp/recman_unit_store.txt"); + CHECK(n == 2 && list_len(l2) == 4); + /* missing file -> error, not crash */ + CHECK(store_load(&l2, "/tmp/recman_unit_missing_xyz.txt") < 0); + list_free(l); + list_free(l2); + remove("/tmp/recman_unit_store.txt"); + return 0; +} + +int main(void) { + if (test_buf() != 0) + return 1; + if (test_list() != 0) + return 1; + if (test_store() != 0) + return 1; + puts("unit: all pass"); + return 0; +}