summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-09-19 06:09:54 +0000
committerEthan Werner <mail@ethanwerner.com>2026-09-19 06:09:54 +0000
commitbf19b9938508f70011395f4dc283ca758ec6a8d3 (patch)
tree46dc4c442ed8d8d43040433c6a4794e76f8e618a
parent96ecf29e8be977f51f351308ef2c7c492bb829aa (diff)
llm toolingHEADmaster
-rw-r--r--.gitignore1
-rw-r--r--Makefile9
-rwxr-xr-xREADME.md26
-rw-r--r--include/clio.h1493
-rw-r--r--include/dotagents.h1282
-rwxr-xr-xinclude/escape.h88
-rw-r--r--include/frontmatter.h1058
-rw-r--r--include/llm.h1539
-rw-r--r--tests/test_ann.c284
-rw-r--r--tests/test_bin.c273
-rw-r--r--tests/test_buffer.c134
-rw-r--r--tests/test_clio.c884
-rw-r--r--tests/test_direct.c157
-rw-r--r--tests/test_dotagents.c524
-rw-r--r--tests/test_escape.c113
-rw-r--r--tests/test_flat.c132
-rw-r--r--tests/test_frontmatter.c236
-rw-r--r--tests/test_genetic.c158
-rw-r--r--tests/test_interpolate.c112
-rw-r--r--tests/test_pool.c150
-rw-r--r--tests/test_pushdown.c442
-rw-r--r--tests/test_stack.c234
-rw-r--r--tests/test_ufo.c143
-rw-r--r--tests/test_uri.c300
24 files changed, 9768 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index 8f21e0c..cacfd65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ test.bin
# Test binaries
tests/test_*
+!tests/test_*.c
# Compiled example binaries
example/ann_xor
diff --git a/Makefile b/Makefile
index a4ad567..3dfde01 100644
--- a/Makefile
+++ b/Makefile
@@ -1,16 +1,17 @@
CC = gcc
-CFLAGS = -Wall -Wextra -Werror -pedantic -std=c99 -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -Wformat-security -Wnull-dereference -I include
+CFLAGS = -Wall -Wextra -Werror -pedantic -std=c99 -O2 -D_FORTIFY_SOURCE=2 -D_POSIX_C_SOURCE=200809L -fstack-protector-strong -Wformat-security -Wnull-dereference -I include
LDFLAGS = -lm
-CFLAGS_DEBUG = -Wall -Wextra -Werror -pedantic -std=c99 -g -O0 -fstack-protector-strong -Wformat-security -Wnull-dereference -I include
+CFLAGS_DEBUG = -Wall -Wextra -Werror -pedantic -std=c99 -g -O0 -D_POSIX_C_SOURCE=200809L -fstack-protector-strong -Wformat-security -Wnull-dereference -I include
LDFLAGS_DEBUG = $(LDFLAGS)
EXAMPLES = ann_xor buffer bin direct escape genetic_ann genetic_char \
genetic_pushdown_btc pool pushdown stack ufo
TESTS = test_stack test_pool test_buffer test_bin test_pushdown \
- test_escape test_direct test_ann test_genetic test_flat \
- test_uri test_ufo test_interpolate
+ test_escape test_direct test_ann test_genetic test_flat \
+ test_uri test_ufo test_interpolate test_frontmatter test_dotagents \
+ test_clio
EXAMPLE_BINS = $(addprefix bin/, $(EXAMPLES))
TEST_BINS = $(addprefix tests/, $(TESTS))
diff --git a/README.md b/README.md
index bb62aa4..b2d4bd7 100755
--- a/README.md
+++ b/README.md
@@ -38,14 +38,34 @@ This is a naive library, and as such, does not provide input validation. If data
A dynamically resizing buffer with embedded metadata. Stores capacity, element count, and element size preceding the data pointer. Automatically doubles capacity when usage exceeds 80%, and halves when below 20% (minimum capacity of 32).
+## clio.h - Pseudo-TUI Layout
+
+A minimal layout layer over escape.h: escape.h owns styling (SGR, cursor motion, clear, color), clio.h owns layout — where text goes, how it wraps and clips, and how a composed frame reaches the terminal. Apps compose frames into an in-memory cell grid (canvas); `clio_flush` diffs the canvas against the last flushed frame and blits only changed rows through escape.h. Non-tty output (pipes, CI) falls back to plain text lines so piped output stays clean. Also provides a pure chunk-fed key decoder (testable without a tty), raw-mode key reading with a bounded queue, and a single-line editor (`clio_readline`) that renders into the canvas. No global state; each `clio_term` is independent.
+
+**Dependencies:** escape.h (styling layer — the `esc_*` functions need one defining TU with `ESCAPE_IMPLEMENTATION`) and type.h.
+
## direct.h - Computation Graph
A computation graph library with topological update ordering. Nodes are connected via edges and evaluated in dependency order using DFS. Uses a generation counter to avoid re-evaluating unchanged nodes. Provides basic arithmetic node functions (add, subtract) as examples.
+## dotagents.h - .agents Protocol (read-only)
+
+Parses and resolves the two-layer `.agents/` directory convention (global `~/.agents/` plus workspace `./.agents/`, workspace winning on conflicts): AGENTS.md guidelines, system prompt, `mcp.json`, `models.json` presets, `speakmcp-settings.json`, `layouts/ui.json`, skills, sub-agent profiles, repeat tasks, and persistent memories. Read-only — it never writes. Load never fails: missing layers and malformed files become warnings, never hard errors. Output is id-sorted for stable diffs.
+
+**Dependencies:** frontmatter.h and libc.
+
## escape.h - ANSI Escape Codes
This library provides utility functions and macros for an assortment of ANSI escape codes, including cursor movement, screen clearing, text formatting, and color output. Each operation is available as both a macro (string literal) and a function (writes to stdout or a given FILE*).
+## frontmatter.h - Markdown Frontmatter Parser
+
+Parses a markdown document with an optional `---` fenced frontmatter block into typed values: `key: value` lines yielding strings (quotes stripped), booleans, integers, and lists (CSV or JSON array). Includes a small embedded JSON value type — parse, serialize, lookup, and shallow merge with ownership transfer (`fm_json_merge_owned`). An unterminated fence means the whole text is body.
+
+**Operations:** `parse_frontmatter`, `doc_get`, `doc_free`, `fm_list_items`, `fm_json_parse` / `fm_json_serialize` / `fm_json_get` / `fm_json_merge_owned`.
+
+**Dependencies:** libc only. Header-only: all functions are `static inline`, safe to include in any number of translation units.
+
## flat.h - Directory Traversal
This library provides a simple wrapper for traversing POSIX file systems. The intention for this library is to be used alongside bin.h, for the purpose of traversing hierarchical directory structures. Provides both POSIX (dirent.h) and Windows (FindFirstFileA) implementations.
@@ -73,6 +93,12 @@ An implementation of a genetic algorithm. The library exposes a single function
A collection of interpolation and easing macros inspired by "Math for Game Programmers: Fast and Funky 1D Nonlinear Transformations". Provides namespaced `INTERP_*` macros for collision safety, legacy unprefixed aliases guarded by `#ifndef`, and function wrappers for debuggability.
+## llm.h - LLM Provider Protocol Layer
+
+Header-only protocol layer for LLM chat endpoints: OpenAI-compatible (`/chat/completions`), Anthropic (`/v1/messages`), and Ollama (`/api/chat`). Request assembly (messages, tools, per-provider shaping), HTTP POST with stop-cancellation, SSE and NDJSON streaming with live delta emission, response and error-body parsing, and `Retry-After` handling. Pure protocol — no application state: messages arrive as borrowed `llm_message` arrays, tools as a pre-built `"tools":[...]` fragment, and tool execution, session access, and emit formatting are supplied by the caller.
+
+**Dependencies:** curl, cJSON, libc only. Header-only: all functions are `static inline`, safe to include in any number of translation units.
+
## pool.h - Memory Pool
A free-list based memory pool. The pool has a fixed size after allocation.
diff --git a/include/clio.h b/include/clio.h
new file mode 100644
index 0000000..8899197
--- /dev/null
+++ b/include/clio.h
@@ -0,0 +1,1493 @@
+/* clio.h — minimal pseudo-TUI layout layer over escape.h.
+ *
+ * escape.h owns styling (SGR, cursor motion, clear, color); clio.h owns
+ * layout: where text goes, how it wraps and clips, and how a composed
+ * frame is blitted to the terminal with the minimal diff of escape
+ * sequences. Apps never emit raw escape codes themselves.
+ *
+ * Model:
+ * clio_term the real terminal: size, capabilities, raw mode, alt
+ * screen, cursor, bracketed-paste/kitty state, key queue,
+ * and the last flushed frame (for diffing).
+ * clio_buf an in-memory cell grid (the canvas). Apps compose frames
+ * here; nothing is written until clio_flush.
+ * clio_flush diff the canvas against the previous frame and blit only
+ * changed rows through escape.h. Non-tty (pipe, CI) falls
+ * back to plain text lines so piped output stays clean.
+ * input a pure, chunk-fed key decoder (testable without a tty),
+ * raw-mode key reading with a bounded queue, and a
+ * single-line editor (clio_readline) that renders into the
+ * canvas.
+ *
+ * Conventions: C99, header-only (static inline), no global state,
+ * extern "C", POSIX-first (termios/poll/TIOCGWINSZ) — Windows is a
+ * documented limitation, as in escape.h.
+ *
+ * Usage: #include "clio.h". The header is complete on its own; escape.h
+ * is pulled in for the styling layer. The esc_* function symbols need one
+ * defining TU with ESCAPE_IMPLEMENTATION (llshcpp's src/escape.c, or the
+ * test TU itself for zero-link builds).
+ *
+ * Layout is column-based: one cell per column, one code point per cell.
+ * Wide characters (width 2) occupy one cell; a wide character landing
+ * in the last column may be clipped by the terminal (v1 limitation).
+ *
+ * API:
+ * style: clio_style, clio_style_default, clio_style_eq
+ * text: clio_utf8_next_n, clio_utf8_enc, clio_cp_width,
+ * clio_text_width, clio_char_start, clio_char_end,
+ * clio_word_back
+ * canvas: clio_buf, clio_cell, clio_buf_init/free/clear,
+ * clio_buf_at, clio_buf_clear_region, clio_buf_fill_region,
+ * clio_buf_put, clio_buf_box, clio_buf_scroll_up
+ * keys: clio_key, CLIOK_*, clio_keydec, clio_keydec_init,
+ * clio_keydec_feed, clio_keydec_flush
+ * terminal: clio_term, clio_color_policy, clio_term_init,
+ * clio_term_resize, clio_term_raw_on/off,
+ * clio_term_alt_on/off, clio_term_cursor_hide/show,
+ * clio_term_paste_on/off, clio_term_kitty_on/off,
+ * clio_term_bell, clio_term_shutdown, clio_term_free
+ * output: clio_fstyle, clio_flush, clio_cursor
+ * input: clio_next_key, clio_next_key_stdin, clio_read_key,
+ * clio_readline
+ *
+ * Concurrency: no shared state; each clio_term is independent.
+ */
+#ifndef CLIO_H
+#define CLIO_H
+
+#include "escape.h"
+#include "type.h"
+
+#include <ctype.h>
+#include <limits.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Style */
+/* ------------------------------------------------------------------ */
+
+/* Attribute bits (OR-combined into clio_style.attr). */
+enum {
+ CLIO_BOLD = 1u,
+ CLIO_DIM = 2u,
+ CLIO_ITALIC = 4u,
+ CLIO_UNDERLINE = 8u,
+ CLIO_REVERSE = 16u,
+ CLIO_STRIKE = 32u,
+};
+
+/* fg/bg: -1 = terminal default, otherwise a 0..255 palette index.
+ * attr: OR of CLIO_* bits. */
+typedef struct clio_style {
+ int fg;
+ int bg;
+ unsigned attr;
+} clio_style;
+
+static inline clio_style clio_style_default(void) {
+ clio_style s;
+ s.fg = -1;
+ s.bg = -1;
+ s.attr = 0;
+ return s;
+}
+
+static inline int clio_style_eq(const clio_style *a, const clio_style *b) {
+ return a->fg == b->fg && a->bg == b->bg && a->attr == b->attr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text (UTF-8 + display width) */
+/* ------------------------------------------------------------------ */
+
+/* Decode the next code point in [p, end); returns the byte length used.
+ * Truncated or invalid sequences still consume at least one byte. */
+static inline size_t clio_utf8_next_n(const char *p, const char *end, u32 *cp) {
+ unsigned char b = (unsigned char) p[0];
+ size_t n;
+ if (b < 0x80)
+ n = 1;
+ else if ((b & 0xE0) == 0xC0)
+ n = 2;
+ else if ((b & 0xF0) == 0xE0)
+ n = 3;
+ else if ((b & 0xF8) == 0xF0)
+ n = 4;
+ else {
+ *cp = 0;
+ return 1;
+ }
+ if ((size_t)(end - p) < n)
+ n = (size_t)(end - p);
+ u32 c = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char ch = (unsigned char) p[i];
+ unsigned int mask =
+ (i == 0) ? (n == 1 ? 0x7Fu : n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u)
+ : 0x3Fu;
+ c = (c << 6) | (u32)(ch & mask);
+ }
+ *cp = c;
+ return n;
+}
+
+static inline size_t clio_utf8_next(const char *s, u32 *cp) {
+ return clio_utf8_next_n(s, s + strlen(s), cp);
+}
+
+/* Encode a code point into out (at least 5 bytes). -1 on invalid input. */
+static inline int clio_utf8_enc(u32 cp, char *out) {
+ size_t n;
+ if (cp < 0x80)
+ n = 1;
+ else if (cp < 0x800)
+ n = 2;
+ else if (cp < 0x10000)
+ n = 3;
+ else if (cp <= 0x10FFFF)
+ n = 4;
+ else
+ return -1;
+ if (cp >= 0xD800 && cp <= 0xDFFF)
+ return -1;
+ unsigned char b[4];
+ switch (n) {
+ case 1:
+ b[0] = (unsigned char) cp;
+ break;
+ case 2:
+ b[0] = (unsigned char)(0xC0 | (cp >> 6));
+ b[1] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ case 3:
+ b[0] = (unsigned char)(0xE0 | (cp >> 12));
+ b[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[2] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ default:
+ b[0] = (unsigned char)(0xF0 | (cp >> 18));
+ b[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F));
+ b[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[3] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ }
+ memcpy(out, b, n);
+ out[n] = '\0';
+ return (int) n;
+}
+
+/* Display width of a code point, locale-independent (wcwidth is not:
+ * glibc's C locale reports 0 for CJK, musl reports 2 — we must be the
+ * same everywhere). 0 = zero-width, 2 = wide (East Asian Wide/Fullwidth
+ * + common emoji), 1 = everything else. Simplified W=|F table. */
+static inline size_t clio_cp_width(u32 cp) {
+ if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp <= 0x9F))
+ return 0;
+ if (cp == 0xAD || cp == 0x200B || cp == 0x200C || cp == 0x200D ||
+ cp == 0x2060 || (cp >= 0x300 && cp <= 0x36F))
+ return 0;
+ if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) ||
+ (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
+ (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) ||
+ (cp >= 0xA960 && cp <= 0xA97F) || (cp >= 0xAC00 && cp <= 0xD7A3) ||
+ (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE19) ||
+ (cp >= 0xFE30 && cp <= 0xFE6F) || (cp >= 0xFF00 && cp <= 0xFF60) ||
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x1F300 && cp <= 0x1F64F) ||
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || (cp >= 0x20000 && cp <= 0x3FFFD))
+ return 2;
+ return 1;
+}
+
+static inline size_t clio_text_width_n(const char *s, size_t n) {
+ size_t w = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0)
+ w += clio_cp_width(cp);
+ p += len;
+ }
+ return w;
+}
+
+static inline size_t clio_text_width(const char *s) {
+ return clio_text_width_n(s, strlen(s));
+}
+
+/* Byte offset of the start of the code point containing pos. */
+static inline size_t clio_char_start(const char *s, size_t pos) {
+ if (pos == 0)
+ return 0;
+ size_t i = pos;
+ while (i > 0 && (((unsigned char) s[i]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset of the start of the code point immediately before pos
+ * (pos is a cursor position, i.e. a char boundary). */
+static inline size_t clio_char_before(const char *s, size_t pos) {
+ size_t i = pos;
+ if (i == 0)
+ return 0;
+ i--; /* last byte belongs to the previous code point */
+ while (i > 0 && (((unsigned char) s[i - 1]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset just past the code point at pos. */
+static inline size_t clio_char_end(const char *s, size_t len, size_t pos) {
+ if (pos >= len)
+ return len;
+ u32 cp;
+ size_t n = clio_utf8_next_n(s + pos, s + len, &cp);
+ return pos + n;
+}
+
+/* Byte offset of the start of the word before pos (ed-style Ctrl+W). */
+static inline size_t clio_word_back(const char *s, size_t pos) {
+ size_t i = pos;
+ while (i > 0 && s[i - 1] == ' ')
+ i--;
+ while (i > 0 && (isalnum((unsigned char) s[i - 1]) || s[i - 1] == '_'))
+ i--;
+ while (i > 0 && s[i - 1] == ' ')
+ i--;
+ return i;
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas (cell grid) */
+/* ------------------------------------------------------------------ */
+
+/* One column of a row. ch is a code point (0 = blank slot; bg/attr still
+ * apply). fg/bg of 0xFFFF mean "terminal default". A blank default cell
+ * is {0, 0xFFFF, 0xFFFF, 0}. */
+typedef struct clio_cell {
+ u32 ch;
+ u16 fg;
+ u16 bg;
+ u16 attr;
+} clio_cell;
+
+typedef struct clio_buf {
+ clio_cell *cells;
+ size_t rows;
+ size_t cols;
+} clio_buf;
+
+static inline u16 clio_style_u16(int v) {
+ return (u16)(v < 0 ? 0xFFFF : (v > 255 ? 255 : v));
+}
+
+static inline void clio_cell_set(clio_cell *c, u32 ch, clio_style st) {
+ c->ch = ch;
+ c->fg = clio_style_u16(st.fg);
+ c->bg = clio_style_u16(st.bg);
+ c->attr = (u16) st.attr;
+}
+
+static inline clio_style clio_cell_style(const clio_cell *c) {
+ clio_style s;
+ s.fg = (c->fg == 0xFFFF) ? -1 : (int) c->fg;
+ s.bg = (c->bg == 0xFFFF) ? -1 : (int) c->bg;
+ s.attr = c->attr;
+ return s;
+}
+
+static inline int clio_cell_eq(const clio_cell *a, const clio_cell *b) {
+ return memcmp(a, b, sizeof(*a)) == 0;
+}
+
+static inline int clio_buf_init(clio_buf *b, size_t rows, size_t cols) {
+ if (rows == 0)
+ rows = 1;
+ if (cols == 0)
+ cols = 1;
+ b->rows = rows;
+ b->cols = cols;
+ b->cells = (clio_cell *) calloc(rows * cols, sizeof(clio_cell));
+ if (!b->cells) {
+ b->rows = b->cols = 0;
+ return -1;
+ }
+ for (size_t i = 0; i < rows * cols; i++) {
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+ return 0;
+}
+
+static inline void clio_buf_free(clio_buf *b) {
+ free(b->cells);
+ b->cells = NULL;
+ b->rows = b->cols = 0;
+}
+
+static inline void clio_buf_clear(clio_buf *b) {
+ for (size_t i = 0; i < b->rows * b->cols; i++) {
+ b->cells[i].ch = 0;
+ b->cells[i].attr = 0;
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+}
+
+static inline clio_cell *clio_buf_at(clio_buf *b, size_t row, size_t col) {
+ if (row >= b->rows || col >= b->cols)
+ return NULL;
+ return &b->cells[row * b->cols + col];
+}
+
+static inline void clio_buf_clear_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+static inline void clio_buf_fill_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1, u32 ch,
+ clio_style st) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++)
+ clio_cell_set(&b->cells[r * b->cols + c], ch, st);
+}
+
+/* Write text at (row, col); wraps onto the next row (when wrap != 0 and
+ * rows remain) and clips at the buffer edges. '\n' moves to the next row
+ * at column 0. Zero-width code points occupy no cell. Returns the number
+ * of bytes written (including newlines). */
+static inline size_t clio_buf_put(clio_buf *b, size_t row, size_t col,
+ const char *text, clio_style st, int wrap) {
+ size_t written = 0;
+ size_t len = strlen(text);
+ const char *p = text;
+ while (p < text + len) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, text + len, &cp);
+ p += n;
+ if (cp == '\n') {
+ row++;
+ col = 0;
+ if (row >= b->rows)
+ break;
+ written += n;
+ continue;
+ }
+ size_t w = clio_cp_width(cp);
+ if (w == 0) {
+ written += n;
+ continue;
+ }
+ if (col + w > b->cols) {
+ if (!wrap || row + 1 >= b->rows)
+ break;
+ row++;
+ col = 0;
+ }
+ for (size_t i = 0; i < w; i++) {
+ if (col + i >= b->cols)
+ break;
+ clio_cell *cell = &b->cells[row * b->cols + (col + i)];
+ cell->ch = (i == 0) ? cp : 0;
+ cell->fg = clio_style_u16(st.fg);
+ cell->bg = clio_style_u16(st.bg);
+ cell->attr = (u16) st.attr;
+ }
+ col += w;
+ written += n;
+ }
+ return written;
+}
+
+/* Draw a single-character border (ch, e.g. U+2502) around the rectangle
+ * (r0, c0) with width w and height h. */
+static inline void clio_buf_box(clio_buf *b, size_t r0, size_t c0, size_t w,
+ size_t h, u32 ch, clio_style st) {
+ if (r0 >= b->rows || c0 >= b->cols || w == 0 || h == 0)
+ return;
+ if (w > b->cols - c0)
+ w = b->cols - c0;
+ if (h > b->rows - r0)
+ h = b->rows - r0;
+ for (size_t x = 0; x < w; x++) {
+ clio_buf_fill_region(b, r0, c0 + x, r0, c0 + x, ch, st);
+ clio_buf_fill_region(b, r0 + h - 1, c0 + x, r0 + h - 1, c0 + x, ch, st);
+ }
+ for (size_t y = 1; y + 1 < h; y++) {
+ clio_buf_fill_region(b, r0 + y, c0, r0 + y, c0, ch, st);
+ clio_buf_fill_region(b, r0 + y, c0 + w - 1, r0 + y, c0 + w - 1, ch, st);
+ }
+}
+
+/* Shift rows [r0, r1) up by one row within columns [c0, c1]; the bottom
+ * row becomes blank. */
+static inline void clio_buf_scroll_up(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 <= r0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows)
+ r1 = b->rows;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r + 1 <= r1; r++)
+ memcpy(&b->cells[r * b->cols + c0], &b->cells[(r + 1) * b->cols + c0],
+ (c1 - c0 + 1) * sizeof(clio_cell));
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r1 * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Key types + pure decoder */
+/* ------------------------------------------------------------------ */
+
+typedef struct clio_key {
+ int kind;
+ u32 cp; /* CLIOK_CHAR: the code point */
+} clio_key;
+
+enum {
+ CLIOK_UNKNOWN = 0,
+ CLIOK_CHAR,
+ CLIOK_ENTER,
+ CLIOK_TAB,
+ CLIOK_BACKTAB,
+ CLIOK_BACKSPACE,
+ CLIOK_DELETE,
+ CLIOK_LEFT,
+ CLIOK_RIGHT,
+ CLIOK_UP,
+ CLIOK_DOWN,
+ CLIOK_HOME,
+ CLIOK_END,
+ CLIOK_PAGEUP,
+ CLIOK_PAGEDOWN,
+ CLIOK_ESC,
+ CLIOK_CTRL_A,
+ CLIOK_CTRL_C,
+ CLIOK_CTRL_D,
+ CLIOK_CTRL_E,
+ CLIOK_CTRL_K,
+ CLIOK_CTRL_L,
+ CLIOK_CTRL_U,
+ CLIOK_CTRL_W,
+ CLIOK_CTRL_Y,
+ CLIOK_CTRL_Z,
+ CLIOK_CTRL_G,
+};
+
+/* State machine for decoding raw terminal input into clio_key values.
+ * Pure: no I/O, no allocation. Feed chunks of any size (byte boundaries
+ * may split UTF-8 or escape sequences); state persists across feeds. */
+typedef struct clio_keydec {
+ int state; /* 0 ground, 1 escape, 2 csi, 3 ss3 */
+ int utf8_n;
+ int utf8_need;
+ u32 utf8[4];
+ int csi_n;
+ unsigned char csi[32];
+ int pasting;
+} clio_keydec;
+
+static inline void clio_keydec_init(clio_keydec *d) {
+ memset(d, 0, sizeof *d);
+}
+
+static inline int clio_kd_ground(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (b == 0x1B) {
+ d->state = 1;
+ return 0;
+ }
+ if (b == 0x0D) {
+ k->kind = CLIOK_ENTER;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x0A) {
+ if (d->pasting) {
+ k->kind = CLIOK_CHAR;
+ k->cp = '\n';
+ return 1;
+ }
+ return 0; /* bare LF outside paste: dead key */
+ }
+ if (b == 0x7F || b == 0x08) {
+ k->kind = CLIOK_BACKSPACE;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x09) {
+ k->kind = CLIOK_TAB;
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x20) {
+ switch (b) {
+ case 0x01u:
+ k->kind = CLIOK_CTRL_A;
+ break;
+ case 0x03u:
+ k->kind = CLIOK_CTRL_C;
+ d->pasting = 0; /* Ctrl+C interrupts a dangling paste */
+ break;
+ case 0x04u:
+ k->kind = CLIOK_CTRL_D;
+ break;
+ case 0x05u:
+ k->kind = CLIOK_CTRL_E;
+ break;
+ case 0x0Bu:
+ k->kind = CLIOK_CTRL_K;
+ break;
+ case 0x0Cu:
+ k->kind = CLIOK_CTRL_L;
+ break;
+ case 0x15u:
+ k->kind = CLIOK_CTRL_U;
+ break;
+ case 0x17u:
+ k->kind = CLIOK_CTRL_W;
+ break;
+ case 0x19u:
+ k->kind = CLIOK_CTRL_Y;
+ break;
+ case 0x1Au:
+ k->kind = CLIOK_CTRL_Z;
+ break;
+ case 0x07u:
+ k->kind = CLIOK_CTRL_G;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x80) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ if (b >= 0xC0 && b < 0xF8) {
+ d->utf8_need = (b >= 0xF0) ? 4 : (b >= 0xE0) ? 3 : 2;
+ d->utf8[0] = b;
+ d->utf8_n = 1;
+ return 0;
+ }
+ if (d->utf8_n > 0 && b >= 0x80 && b < 0xC0) {
+ d->utf8[d->utf8_n++] = b;
+ if (d->utf8_n < d->utf8_need)
+ return 0;
+ int n = d->utf8_n;
+ d->utf8_n = 0;
+ u32 cp =
+ d->utf8[0] & (n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u);
+ for (int i = 1; i < n; i++)
+ cp = (cp << 6) | (d->utf8[i] & 0x3Fu);
+ k->kind = CLIOK_CHAR;
+ k->cp = cp;
+ return 1;
+ }
+ d->utf8_n = 0;
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = b;
+ return 1;
+}
+
+static inline int clio_kd_escape(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ if (b == '[') {
+ d->state = 2;
+ d->csi_n = 0;
+ return 0;
+ }
+ if (b == 'O') {
+ d->state = 3;
+ return 0;
+ }
+ if (b >= 0x20 && b <= 0x7E) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_csi(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (d->csi_n < (int) sizeof d->csi)
+ d->csi[d->csi_n++] = b;
+ if ((b >= '0' && b <= '9') || b == ';' || b == '?' || b == '<' || b == '=' ||
+ b == '>')
+ return 0;
+ d->state = 0;
+ unsigned long num = 0;
+ int any = 0;
+ for (int i = 0; i < d->csi_n - 1; i++) {
+ unsigned char p = d->csi[i];
+ if (p < '0' || p > '9')
+ break;
+ if (any)
+ num = num * 10 + (unsigned long)(p - '0');
+ else
+ num = (unsigned long)(p - '0');
+ any = 1;
+ if (num > 0xFFFFu)
+ break;
+ }
+ d->csi_n = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ k->cp = 0;
+ return 1;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ k->cp = 0;
+ return 1;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ k->cp = 0;
+ return 1;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ k->cp = 0;
+ return 1;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ k->cp = 0;
+ return 1;
+ case 'F':
+ k->kind = CLIOK_END;
+ k->cp = 0;
+ return 1;
+ case 'Z':
+ k->kind = CLIOK_BACKTAB;
+ k->cp = 0;
+ return 1;
+ case '~':
+ switch (num) {
+ case 1u:
+ case 7u:
+ k->kind = CLIOK_HOME;
+ break;
+ case 3u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 4u:
+ case 8u:
+ k->kind = CLIOK_END;
+ break;
+ case 5u:
+ k->kind = CLIOK_PAGEUP;
+ break;
+ case 6u:
+ k->kind = CLIOK_PAGEDOWN;
+ break;
+ case 200u:
+ d->pasting = 1;
+ return 0;
+ case 201u:
+ d->pasting = 0;
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ case 'u': /* kitty keyboard protocol */
+ switch (num) {
+ case 9u:
+ k->kind = CLIOK_TAB;
+ break;
+ case 12u:
+ k->kind = CLIOK_BACKSPACE;
+ break;
+ case 13u:
+ k->kind = CLIOK_ENTER;
+ break;
+ case 27u:
+ k->kind = CLIOK_ESC;
+ break;
+ case 127u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 0x2190u:
+ k->kind = CLIOK_LEFT;
+ break;
+ case 0x2191u:
+ k->kind = CLIOK_UP;
+ break;
+ case 0x2192u:
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 0x2193u:
+ k->kind = CLIOK_DOWN;
+ break;
+ default:
+ k->cp = 0;
+ return 0;
+ }
+ k->cp = 0;
+ return 1;
+ case 'R': /* cursor position report: swallow */
+ case 'M':
+ case 'h':
+ case 'l':
+ case 'm':
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = 0;
+ return 1;
+ }
+}
+
+static inline int clio_kd_ss3(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ break;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ break;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ break;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ break;
+ case 'F':
+ k->kind = CLIOK_END;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_step(clio_keydec *d, unsigned char b, clio_key *k) {
+ switch (d->state) {
+ case 1:
+ return clio_kd_escape(d, b, k);
+ case 2:
+ return clio_kd_csi(d, b, k);
+ case 3:
+ return clio_kd_ss3(d, b, k);
+ default:
+ return clio_kd_ground(d, b, k);
+ }
+}
+
+/* Feed raw bytes; emits up to cap decoded keys into out; returns the
+ * number emitted. */
+static inline size_t clio_keydec_feed(clio_keydec *d, const unsigned char *in,
+ size_t n, clio_key *out, size_t cap) {
+ size_t emitted = 0;
+ for (size_t i = 0; i < n; i++) {
+ clio_key k;
+ if (clio_kd_step(d, in[i], &k) && emitted < cap)
+ out[emitted++] = k;
+ }
+ return emitted;
+}
+
+/* Emit a trailing bare ESC (state 1) as a key. 1 if emitted. */
+static inline int clio_keydec_flush(clio_keydec *d, clio_key *k) {
+ if (d->state != 1)
+ return 0;
+ d->state = 0;
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* Terminal state */
+/* ------------------------------------------------------------------ */
+
+#define CLIO_KEYQ 32
+
+typedef struct clio_term {
+ int is_tty;
+ int color; /* clio_color_policy() result */
+ size_t rows;
+ size_t cols;
+ int raw;
+ struct termios saved;
+ int alt;
+ int cursor_hidden;
+ int paste;
+ int kitty;
+ clio_keydec dec;
+ clio_key q[CLIO_KEYQ];
+ size_t qh;
+ size_t qt;
+ clio_buf last;
+ int has_last;
+} clio_term;
+
+/* CLICOLOR_FORCE (non-empty, not "0") wins; otherwise color only on a tty
+ * without NO_COLOR, TERM=dumb, or CI. */
+static inline int clio_color_policy(int tty) {
+ const char *force = getenv("CLICOLOR_FORCE");
+ if (force && force[0]) {
+ if (strcmp(force, "0") == 0)
+ return 0;
+ return 1;
+ }
+ if (!tty)
+ return 0;
+ if (getenv("NO_COLOR"))
+ return 0;
+ const char *term = getenv("TERM");
+ if (term && strcmp(term, "dumb") == 0)
+ return 0;
+ if (getenv("CI"))
+ return 0;
+ return 1;
+}
+
+static inline int clio_term_init(clio_term *t) {
+ memset(t, 0, sizeof *t);
+ t->is_tty = isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0;
+ t->color = clio_color_policy(t->is_tty);
+ t->rows = 24;
+ t->cols = 80;
+ struct winsize ws;
+ if (t->is_tty && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 &&
+ ws.ws_row > 0 && ws.ws_col > 0) {
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ }
+ clio_keydec_init(&t->dec);
+ return 0;
+}
+
+/* Re-read the window size; invalidates the flushed-frame cache. -1 on
+ * failure (previous size kept). */
+static inline int clio_term_resize(clio_term *t) {
+ struct winsize ws;
+ if (!t->is_tty)
+ return -1;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0)
+ return -1;
+ if (ws.ws_row == 0 || ws.ws_col == 0)
+ return -1;
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+ return 0;
+}
+
+static inline int clio_term_raw_on(clio_term *t) {
+ if (t->raw || !t->is_tty)
+ return -1;
+ if (tcgetattr(STDIN_FILENO, &t->saved) != 0)
+ return -1;
+ struct termios raw = t->saved;
+ raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
+ raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG));
+ raw.c_oflag &= ~((tcflag_t) OPOST);
+ raw.c_cc[VMIN] = 1;
+ raw.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0)
+ return -1;
+ t->raw = 1;
+ return 0;
+}
+
+static inline void clio_term_raw_off(clio_term *t) {
+ if (!t->raw)
+ return;
+ tcsetattr(STDIN_FILENO, TCSANOW, &t->saved);
+ t->raw = 0;
+}
+
+static inline void clio_term_alt_on(clio_term *t) {
+ if (t->alt)
+ return;
+ esc_falt_screen_on(stdout);
+ fflush(stdout);
+ t->alt = 1;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_alt_off(clio_term *t) {
+ if (!t->alt)
+ return;
+ esc_falt_screen_off(stdout);
+ fflush(stdout);
+ t->alt = 0;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_cursor_hide(clio_term *t) {
+ if (t->cursor_hidden)
+ return;
+ esc_fcursor_hide(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 1;
+}
+
+static inline void clio_term_cursor_show(clio_term *t) {
+ if (!t->cursor_hidden)
+ return;
+ esc_fcursor_show(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 0;
+}
+
+static inline void clio_term_paste_on(clio_term *t) {
+ if (t->paste)
+ return;
+ esc_fbracketed_paste_on(stdout);
+ fflush(stdout);
+ t->paste = 1;
+ t->dec.pasting = 1;
+}
+
+static inline void clio_term_paste_off(clio_term *t) {
+ if (!t->paste)
+ return;
+ esc_fbracketed_paste_off(stdout);
+ fflush(stdout);
+ t->paste = 0;
+ t->dec.pasting = 0;
+}
+
+static inline void clio_term_kitty_on(clio_term *t) {
+ if (t->kitty)
+ return;
+ esc_fkitty_on(stdout);
+ fflush(stdout);
+ t->kitty = 1;
+}
+
+static inline void clio_term_kitty_off(clio_term *t) {
+ if (!t->kitty)
+ return;
+ esc_fkitty_off(stdout);
+ fflush(stdout);
+ t->kitty = 0;
+}
+
+static inline void clio_term_bell(clio_term *t) {
+ (void) t;
+ esc_fbell(stdout);
+ fflush(stdout);
+}
+
+/* Restore everything clio_term turned on (idempotent). */
+static inline void clio_term_shutdown(clio_term *t) {
+ if (t->kitty)
+ clio_term_kitty_off(t);
+ if (t->paste)
+ clio_term_paste_off(t);
+ if (t->cursor_hidden)
+ clio_term_cursor_show(t);
+ if (t->alt)
+ clio_term_alt_off(t);
+ clio_term_raw_off(t);
+}
+
+static inline void clio_term_free(clio_term *t) {
+ clio_term_shutdown(t);
+ clio_buf_free(&t->last);
+ memset(t, 0, sizeof *t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Output (diff flush) */
+/* ------------------------------------------------------------------ */
+
+/* Map a style onto the stream: reset, then attributes, then 256-color
+ * fg/bg. escape.h emits the codes; clio.h only decides what goes. */
+static inline void clio_fstyle(FILE *f, clio_style st) {
+ if (!f)
+ return;
+ fputs(ESC_RESET, f);
+ if (st.attr & CLIO_BOLD)
+ fputs(ESC_BOLD, f);
+ if (st.attr & CLIO_DIM)
+ fputs(ESC_DIM, f);
+ if (st.attr & CLIO_ITALIC)
+ fputs(ESC_ITALIC, f);
+ if (st.attr & CLIO_UNDERLINE)
+ fputs(ESC_UNDERLINE, f);
+ if (st.attr & CLIO_REVERSE)
+ fputs(ESC_REVERSE, f);
+ if (st.attr & CLIO_STRIKE)
+ fputs(ESC_STRIKETHROUGH, f);
+ if (st.fg >= 0)
+ esc_fcolor_fg_256(f, (u32)(st.fg & 0xFF));
+ if (st.bg >= 0)
+ esc_fcolor_bg_256(f, (u32)(st.bg & 0xFF));
+}
+
+/* Render one row: style runs + text. Blank cells print as spaces. */
+static inline void clio_emit_row(FILE *f, const clio_buf *b, size_t row) {
+ clio_style cur = clio_style_default();
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[row * b->cols + c];
+ clio_style st = clio_cell_style(cell);
+ if (!clio_style_eq(&st, &cur)) {
+ clio_fstyle(f, st);
+ cur = st;
+ }
+ if (cell->ch == 0) {
+ fputc(' ', f);
+ } else if (cell->ch < 0x80) {
+ fputc((int)(cell->ch & 0xFF), f);
+ } else {
+ char tmp[8];
+ if (clio_utf8_enc(cell->ch, tmp) > 0)
+ fputs(tmp, f);
+ else
+ fputc(' ', f);
+ }
+ }
+}
+
+/* Non-tty fallback: plain text lines, trailing blanks trimmed. */
+static inline void clio_flush_plain(const clio_buf *b) {
+ size_t cap = b->cols * 4 + 1;
+ char *row = (char *) malloc(cap);
+ if (!row)
+ return;
+ for (size_t r = 0; r < b->rows; r++) {
+ size_t n = 0;
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[r * b->cols + c];
+ if (cell->ch == 0) {
+ row[n++] = ' ';
+ } else if (cell->ch < 0x80) {
+ row[n++] = (char)(cell->ch & 0xFF);
+ } else {
+ int e = clio_utf8_enc(cell->ch, row + n);
+ if (e > 0)
+ n += (size_t) e;
+ else
+ row[n++] = ' ';
+ }
+ }
+ while (n > 0 && row[n - 1] == ' ')
+ n--;
+ fwrite(row, 1, n, stdout);
+ fputc('\n', stdout);
+ }
+ free(row);
+ fflush(stdout);
+}
+
+/* Blit the canvas to the terminal. TTY: diff against the previous frame,
+ * clear screen + full repaint on first flush or size change, otherwise
+ * only changed rows. Non-tty: plain text lines (no escapes, safe to pipe). */
+static inline void clio_flush(clio_term *t, const clio_buf *b) {
+ if (!b || !b->cells)
+ return;
+ if (!t->is_tty) {
+ clio_flush_plain(b);
+ return;
+ }
+ int full = 1;
+ if (t->has_last && t->last.rows == b->rows && t->last.cols == b->cols)
+ full = 0;
+ if (full) {
+ clio_buf_free(&t->last);
+ if (clio_buf_init(&t->last, b->rows, b->cols) != 0) {
+ t->has_last = 0;
+ return;
+ }
+ t->has_last = 1;
+ esc_fclear_screen(stdout);
+ for (size_t r = 0; r < b->rows; r++) {
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ } else {
+ for (size_t r = 0; r < b->rows; r++) {
+ int same = 1;
+ for (size_t c = 0; c < b->cols; c++) {
+ if (!clio_cell_eq(&t->last.cells[r * b->cols + c],
+ &b->cells[r * b->cols + c])) {
+ same = 0;
+ break;
+ }
+ }
+ if (same)
+ continue;
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ }
+ memcpy(t->last.cells, b->cells, b->rows * b->cols * sizeof(clio_cell));
+ fflush(stdout);
+}
+
+/* Move the visible cursor (0-based row/col, clamped to the terminal). */
+static inline void clio_cursor(clio_term *t, size_t row, size_t col) {
+ if (!t->is_tty)
+ return;
+ if (row > t->rows)
+ row = t->rows;
+ if (col > t->cols)
+ col = t->cols;
+ esc_fcursor_position(stdout, (u32)(col + 1), (u32)(row + 1));
+ fflush(stdout);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key input */
+/* ------------------------------------------------------------------ */
+
+/* Key source: 1 + *k set on success, 0 on timeout/EOF. */
+typedef int (*clio_next_key)(void *ud, clio_key *k, int timeout_ms);
+
+static inline int clio_read_key(clio_term *t, clio_key *out, int timeout_ms) {
+ for (;;) {
+ if (t->qt != t->qh) {
+ *out = t->q[t->qh];
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ return 1;
+ }
+ if (timeout_ms == 0)
+ return clio_keydec_flush(&t->dec, out);
+ struct pollfd pfd;
+ pfd.fd = STDIN_FILENO;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ int pr = poll(&pfd, 1, timeout_ms);
+ if (pr <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ unsigned char in[16];
+ ssize_t n = read(STDIN_FILENO, in, sizeof in);
+ if (n <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ clio_key tmp[16];
+ size_t got = clio_keydec_feed(&t->dec, in, (size_t) n, tmp, 16);
+ for (size_t i = 0; i < got; i++) {
+ if (t->qt == t->qh)
+ return clio_keydec_flush(&t->dec, out);
+ size_t slot = t->qt;
+ if (slot == CLIO_KEYQ)
+ slot = 0;
+ if (slot == t->qh) {
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ } /* queue full: drop the oldest */
+ t->q[slot] = tmp[i];
+ t->qt = (slot + 1 == CLIO_KEYQ) ? 0 : slot + 1;
+ }
+ }
+}
+
+static inline int clio_next_key_stdin(void *ud, clio_key *k, int timeout_ms) {
+ clio_term *t = (clio_term *) ud;
+ return clio_read_key(t, k, timeout_ms);
+}
+
+/* ------------------------------------------------------------------ */
+/* Single-line editor */
+/* ------------------------------------------------------------------ */
+
+static inline void *clio_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "clio: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+typedef struct clio_str {
+ char *s;
+ size_t len;
+ size_t cap;
+} clio_str;
+
+static inline void clio_str_init(clio_str *x) {
+ x->s = NULL;
+ x->len = 0;
+ x->cap = 0;
+}
+
+static inline void clio_str_free(clio_str *x) {
+ free(x->s);
+ x->s = NULL;
+ x->len = x->cap = 0;
+}
+
+static inline void clio_str_ins(clio_str *x, size_t at, const char *s, size_t n) {
+ if (x->len + n + 1 > x->cap) {
+ size_t nc = x->cap ? x->cap : 16;
+ while (x->len + n + 1 > nc)
+ nc *= 2;
+ x->s = (char *) clio_xrealloc(x->s, nc);
+ x->cap = nc;
+ }
+ memmove(x->s + at + n, x->s + at, x->len - at);
+ memcpy(x->s + at, s, n);
+ x->len += n;
+ x->s[x->len] = '\0';
+}
+
+static inline void clio_str_del(clio_str *x, size_t at, size_t n) {
+ if (at > x->len)
+ at = x->len;
+ if (at + n > x->len)
+ n = x->len - at;
+ memmove(x->s + at, x->s + at + n, x->len - at - n);
+ x->len -= n;
+ x->s[x->len] = '\0';
+}
+
+/* Count visible (non-zero-width) code points in the first n bytes. */
+static inline size_t clio_visible_n(const char *s, size_t n) {
+ size_t c = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0 && clio_cp_width(cp) > 0)
+ c++;
+ p += len;
+ }
+ return c;
+}
+
+/* Paint the prompt + text (echo, or one '*' per visible code point) into
+ * the canvas at row; returns the caret column.
+ *
+ * The input is ONE visual line: it never wraps. When the caret passes the
+ * right edge the window slides (bash/readline style) so the caret always
+ * stays visible, so backspace/left-arrow work across the "wrap". */
+static inline size_t clio_readline_paint(clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ const clio_str *txt, size_t caret,
+ clio_style tstyle, int echo) {
+ clio_buf_clear_region(b, row, 0, row, b->cols - 1);
+ size_t pw = prompt ? clio_text_width(prompt) : 0;
+ if (prompt && prompt[0])
+ clio_buf_put(b, row, 0, prompt, pstyle, 0);
+ size_t area = (pw < b->cols) ? b->cols - pw : 0;
+ if (txt->len == 0 || area == 0)
+ return pw;
+ if (echo) {
+ size_t cw = clio_text_width_n(txt->s, caret);
+ /* Window left edge (display cells): 0 unless the caret does not
+ * fit, in which case pin the caret to the last column. */
+ size_t L = (cw + 1 > area) ? cw - (area - 1) : 0;
+ /* Largest char boundary <= L (a wide char straddling L is kept
+ * whole, window starts at it). */
+ size_t off = 0, start = 0;
+ const char *p = txt->s;
+ const char *end = txt->s + txt->len;
+ while (p < end) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, end, &cp);
+ size_t w = (cp != 0) ? clio_cp_width(cp) : 0;
+ if (off + w > L) {
+ start = (size_t)(p - txt->s); /* `off` is the last boundary <= L */
+ break;
+ }
+ off += w;
+ p += n;
+ }
+ clio_buf_put(b, row, pw, txt->s + start, tstyle, 0);
+ size_t c = pw + (cw - off);
+ if (c >= b->cols) /* wide char straddled the left edge */
+ c = b->cols - 1;
+ return c;
+ }
+ size_t total = clio_visible_n(txt->s, txt->len);
+ size_t cw = clio_visible_n(txt->s, caret);
+ size_t first = (cw + 1 > area) ? cw - (area - 1) : 0;
+ for (size_t i = first; i < total; i++)
+ clio_buf_put(b, row, pw + (i - first), "*", tstyle, 0);
+ return pw + (cw - first);
+}
+
+/* Single-line editor. Renders into ONE canvas row (no wrapping; long
+ * input scrolls to keep the caret visible); reads keys from nk (NULL
+ * = clio_next_key_stdin with ud = t). Editing: printable insert, Tab,
+ * backspace/delete, left/right/home/end, Ctrl+A/E/U/K/W, Ctrl+C and ESC
+ * cancel (NULL), Ctrl+D deletes (EOF/submit when empty), Ctrl+L repaints,
+ * Enter submits. Returns the input (caller frees) or NULL. */
+static inline char *clio_readline(clio_term *t, clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ clio_style tstyle, int echo,
+ clio_next_key nk, void *ud, int timeout_ms) {
+ if (!nk) {
+ nk = clio_next_key_stdin;
+ ud = t;
+ }
+ clio_str txt;
+ clio_str_init(&txt);
+ size_t caret = 0;
+
+ size_t col =
+ clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+
+ int quit = 0;
+ for (;;) {
+ clio_key k;
+ if (!nk(ud, &k, timeout_ms))
+ break; /* timeout / EOF / no key */
+ int done = 0;
+ switch (k.kind) {
+ case CLIOK_CHAR: {
+ char tmp[8];
+ int e = clio_utf8_enc(k.cp, tmp);
+ if (e > 0) {
+ clio_str_ins(&txt, caret, tmp, (size_t) e);
+ caret += (size_t) e;
+ }
+ break;
+ }
+ case CLIOK_TAB:
+ clio_str_ins(&txt, caret, "\t", 1);
+ caret++;
+ break;
+ case CLIOK_ENTER:
+ done = 1;
+ break;
+ case CLIOK_BACKSPACE:
+ if (caret > 0) {
+ size_t s = clio_char_before(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ }
+ break;
+ case CLIOK_DELETE:
+ if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_LEFT:
+ if (caret > 0)
+ caret = clio_char_before(txt.s, caret);
+ break;
+ case CLIOK_RIGHT:
+ if (caret < txt.len)
+ caret = clio_char_end(txt.s, txt.len, caret);
+ break;
+ case CLIOK_HOME:
+ case CLIOK_CTRL_A:
+ caret = 0;
+ break;
+ case CLIOK_END:
+ case CLIOK_CTRL_E:
+ caret = txt.len;
+ break;
+ case CLIOK_CTRL_U:
+ clio_str_del(&txt, 0, caret);
+ caret = 0;
+ break;
+ case CLIOK_CTRL_K:
+ clio_str_del(&txt, caret, txt.len - caret);
+ break;
+ case CLIOK_CTRL_W: {
+ size_t s = clio_word_back(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ break;
+ }
+ case CLIOK_CTRL_C:
+ quit = 1;
+ break;
+ case CLIOK_CTRL_D:
+ if (txt.len == 0)
+ done = 1;
+ else if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_CTRL_L:
+ if (t->is_tty) {
+ esc_fclear_screen(stdout);
+ esc_fcursor_position(stdout, 1, 1);
+ fflush(stdout);
+ }
+ break;
+ case CLIOK_ESC:
+ quit = 1;
+ break;
+ default:
+ break;
+ }
+ col = clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+ if (done || quit)
+ break;
+ }
+
+ if (quit || txt.len == 0) {
+ clio_str_free(&txt);
+ return NULL; /* cancelled, or submitted empty */
+ }
+ return txt.s; /* caller frees */
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CLIO_H */
diff --git a/include/dotagents.h b/include/dotagents.h
new file mode 100644
index 0000000..659e156
--- /dev/null
+++ b/include/dotagents.h
@@ -0,0 +1,1282 @@
+/* dotagents.h — portable, header-only implementation of the .agents protocol.
+ *
+ * The .agents protocol (dotagentsprotocol.com) is an open directory
+ * convention for AI agent configuration: one `.agents/` directory holds
+ * MCP server config, agent guidelines (AGENTS.md-compatible), skills,
+ * sub-agent profiles, repeat tasks, persistent memories, and model presets —
+ * in plain, version-controllable files.
+ *
+ * This library is the read-only side: it parses and resolves two layers —
+ * global `~/.agents/` and workspace `./.agents/` — with workspace winning on
+ * conflicts. It does not write.
+ *
+ * Directory schema (per layer):
+ * agents.md instructions (AGENTS.md-compatible)
+ * system-prompt.md system prompt
+ * mcp.json MCP server configuration
+ * models.json model presets & provider keys
+ * speakmcp-settings.json general settings
+ * layouts/ui.json UI/layout prefs
+ * skills/<id>/skill.md skill definition
+ * agents/<id>/agent.md sub-agent profile (+ optional config.json)
+ * tasks/<id>/task.md repeat task definition
+ * memories/ persistent memory entries (markdown files)
+ * .backups/ ignored (write-side artifact)
+ *
+ * Merge rules (final = global + workspace, workspace wins):
+ * - agents.md / system-prompt.md: whole-file replacement (workspace wins)
+ * - models.json: `default` replaced; `presets` merged by preset name
+ * (workspace wins per name)
+ * - mcp.json: `mcpServers` merged by server name (workspace wins per name)
+ * - speakmcp-settings.json / layouts/ui.json: shallow-merged by top-level
+ * key (workspace wins per key)
+ * - skills / agents / tasks / memories: merged by id (workspace wins per id)
+ * - unknown files and malformed files: warned about, skipped (never fatal)
+ *
+ * Conformance (read-only):
+ * [x] agents.md, system-prompt.md, mcp.json, models.json
+ * [x] speakmcp-settings.json, layouts/ui.json
+ * [x] skills/, agents/ (+ config.json), tasks/, memories/
+ * [x] frontmatter: quoted values, CSV + JSON-array lists, bool, int
+ * [x] task `profileId`, agent `connection-type`
+ * [x] case-insensitive file discovery (AGENTS.MD, SKILL.md, ...)
+ * [x] deterministic id-sorted output for stable diffs
+ * [ ] write side (atomic writes, .backups/ rotation) — out of scope
+ * [ ] .dotagents bundles / Hub — out of scope
+ *
+ * Usage: #include "dotagents.h" in your code. Requires frontmatter.h in the
+ * same directory (or on the include path). Zero dependencies beyond
+ * frontmatter.h and libc. All functions are static inline; safe to include
+ * in any number of translation units.
+ *
+ * API:
+ * dotagents_load(out) load ~/.agents + ./.agents (cwd)
+ * dotagents_load_from(out, ws, global) load two explicit layer dirs
+ * dotagents_free(out) free everything
+ * dotagents_project_sections(d, cap) guidelines + skill index + memory note
+ * dotagents_compose_preamble(d, base, cap) base + sections
+ * dotagents_find_skill(d, id) skill or NULL
+ * dotagents_enabled_sub_agent(d, id) enabled sub-agent or NULL
+ * dotagents_mcp_config(d, name) raw per-server JSON or NULL
+ * models_file_get(models, name) preset or NULL
+ * models_file_free(models) free a models_file
+ * memory_importance_rank(m) 0 high, 1 medium, 2 else
+ * parse_frontmatter / doc_get / doc_free / fm_list_items ...
+ * from frontmatter.h (re-exported)
+ *
+ * Memory: all strings are owned by the `dotagents` struct until
+ * dotagents_free. Load never fails: missing layers and malformed files are
+ * recorded in `out->warnings` (or silently skipped when absent).
+ */
+#ifndef DOTAGENTS_H
+#define DOTAGENTS_H
+
+#include "frontmatter.h"
+
+#include <dirent.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LAYER_GLOBAL,
+ LAYER_WORKSPACE,
+} layer;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *description;
+ bool enabled;
+ char *body;
+ layer layer;
+} skill;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *description;
+ char *role;
+ char *connection_type; /* `connection-type` frontmatter, or NULL */
+ bool enabled;
+ char *body;
+ char *config; /* raw JSON text, or NULL */
+ layer layer;
+} sub_agent;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *profile_id; /* `profileId` frontmatter, or NULL */
+ unsigned long interval_minutes;
+ bool has_interval;
+ bool enabled;
+ bool run_on_startup;
+ char *body;
+ layer layer;
+} task_entry;
+
+typedef struct {
+ char *id;
+ char *title;
+ char *importance;
+ char **tags;
+ size_t tag_count;
+ char *summary;
+ char *body;
+ layer layer;
+} memory;
+
+typedef struct {
+ char *name;
+ char *provider;
+ char *model;
+ char *base_url;
+ char *api_key;
+ unsigned long max_turns;
+ bool has_max_turns;
+ unsigned long context_limit;
+ bool has_context_limit;
+} model_preset;
+
+typedef struct {
+ char *default_name;
+ model_preset *presets;
+ size_t preset_count;
+} models_file;
+
+typedef struct {
+ layer *found_layers;
+ char **found_paths;
+ size_t found_count;
+ fm_strlist warnings;
+ char *system_prompt;
+ char *agents_md;
+ char *settings_json; /* merged speakmcp-settings.json, or NULL */
+ char *layouts_json; /* merged layouts/ui.json, or NULL */
+ fm_json *settings; /* accumulated merge tree (transient) */
+ fm_json *layouts; /* accumulated merge tree (transient) */
+ models_file models;
+ bool has_models;
+ char **mcp_names;
+ char **mcp_configs; /* raw JSON per server */
+ size_t mcp_count;
+ skill *skills;
+ size_t skill_count;
+ sub_agent *sub_agents;
+ size_t sub_agent_count;
+ task_entry *tasks;
+ size_t task_count;
+ memory *memories;
+ size_t memory_count;
+} dotagents;
+
+/* qsort comparators (replace the old O(n^2) inline sorts) */
+typedef struct { char *name; char *path; } da_dirent;
+static int da_cmp_dirent(const void *a, const void *b) {
+ return strcmp(((const da_dirent *)a)->name, ((const da_dirent *)b)->name);
+}
+static int da_cmp_skill_id(const void *a, const void *b) {
+ return strcmp(((const skill *)a)->id, ((const skill *)b)->id);
+}
+static int da_cmp_subagent_id(const void *a, const void *b) {
+ return strcmp(((const sub_agent *)a)->id, ((const sub_agent *)b)->id);
+}
+static int da_cmp_task_id(const void *a, const void *b) {
+ return strcmp(((const task_entry *)a)->id, ((const task_entry *)b)->id);
+}
+static int da_cmp_memory_id(const void *a, const void *b) {
+ return strcmp(((const memory *)a)->id, ((const memory *)b)->id);
+}
+static int da_cmp_cstr(const void *a, const void *b) {
+ return strcmp(*(const char *const *)a, *(const char *const *)b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer helpers */
+/* ------------------------------------------------------------------ */
+
+static inline const char *layer_short(layer l) {
+ return l == LAYER_GLOBAL ? "global" : "workspace";
+}
+
+static inline const char *layer_display(layer l) {
+ return l == LAYER_GLOBAL ? "~/.agents" : ".agents";
+}
+
+/* ------------------------------------------------------------------ */
+/* File I/O */
+/* ------------------------------------------------------------------ */
+
+static inline bool da_is_file(const char *p) {
+ struct stat st;
+ return stat(p, &st) == 0 && S_ISREG(st.st_mode);
+}
+
+static inline bool da_is_dir(const char *p) {
+ struct stat st;
+ return stat(p, &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+/* Read a whole file. 0 on success, -1 on any failure. */
+static inline int da_read_file(const char *path, char **out, size_t *out_len) {
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return -1;
+ fm_buf b;
+ fm_buf_init(&b);
+ char chunk[8192];
+ size_t r;
+ while ((r = fread(chunk, 1, sizeof chunk, f)) > 0)
+ fm_buf_putn(&b, chunk, r);
+ int fe = ferror(f);
+ fclose(f);
+ if (fe) {
+ fm_buf_free(&b);
+ return -1;
+ }
+ size_t total = b.len;
+ if (out)
+ *out = fm_buf_steal(&b);
+ if (out_len)
+ *out_len = total;
+ return 0;
+}
+
+static inline bool da_dir_has_entry(const char *dir) {
+ DIR *d = opendir(dir);
+ if (!d)
+ return false;
+ bool has = readdir(d) != NULL;
+ closedir(d);
+ return has;
+}
+
+/* Find `{stem}.md` case-insensitively directly inside dir. */
+static inline char *da_find_md(const char *dir, const char *stem) {
+ char *want = fm_strfmt("%s.md", stem);
+ for (char *p = want; *p; p++)
+ *p = (char)tolower((unsigned char)*p);
+ DIR *d = opendir(dir);
+ if (!d) {
+ free(want);
+ return NULL;
+ }
+ char *result = NULL;
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ if (strcasecmp(e->d_name, want) == 0) {
+ result = fm_strfmt("%s/%s", dir, e->d_name);
+ break;
+ }
+ }
+ closedir(d);
+ free(want);
+ return result;
+}
+
+/* Sorted subdirectories of `dir`: returns name+path arrays. */
+static inline void da_sub_dirs(const char *dir, char ***out_names,
+ char ***out_paths, size_t *out_count) {
+ *out_count = 0;
+ DIR *d = opendir(dir);
+ if (!d)
+ return;
+ size_t cap = 8, n = 0;
+ char **names = (char **)fm_xmalloc(cap * sizeof(char *));
+ char **paths = (char **)fm_xmalloc(cap * sizeof(char *));
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
+ continue;
+ char *path = fm_strfmt("%s/%s", dir, e->d_name);
+ if (!da_is_dir(path)) {
+ free(path);
+ continue;
+ }
+ if (n + 1 > cap) {
+ cap *= 2;
+ names = (char **)fm_xrealloc(names, cap * sizeof(char *));
+ paths = (char **)fm_xrealloc(paths, cap * sizeof(char *));
+ }
+ names[n] = fm_strdup(e->d_name);
+ paths[n] = path;
+ n++;
+ }
+ closedir(d);
+ /* sort by name (keep the parallel paths array in lockstep) */
+ if (n > 1) {
+ da_dirent *ents = (da_dirent *)fm_xmalloc(n * sizeof *ents);
+ for (size_t i = 0; i < n; i++) {
+ ents[i].name = names[i];
+ ents[i].path = paths[i];
+ }
+ qsort(ents, n, sizeof *ents, da_cmp_dirent);
+ for (size_t i = 0; i < n; i++) {
+ names[i] = ents[i].name;
+ paths[i] = ents[i].path;
+ }
+ free(ents);
+ }
+ *out_names = names;
+ *out_paths = paths;
+ *out_count = n;
+}
+
+/* Does this root hold any recognizable protocol content? */
+static inline bool da_has_protocol_content(const char *root) {
+ const char *stems[2] = {"agents", "system-prompt"};
+ for (size_t i = 0; i < 2; i++) {
+ char *md = da_find_md(root, stems[i]);
+ if (md) {
+ free(md);
+ return true;
+ }
+ }
+ const char *files[4] = {"models.json", "mcp.json",
+ "speakmcp-settings.json", "layouts/ui.json"};
+ for (size_t i = 0; i < 4; i++) {
+ char *p = fm_strfmt("%s/%s", root, files[i]);
+ bool has = da_is_file(p);
+ free(p);
+ if (has)
+ return true;
+ }
+ const char *subs[4] = {"skills", "agents", "tasks", "memories"};
+ for (size_t i = 0; i < 4; i++) {
+ char *p = fm_strfmt("%s/%s", root, subs[i]);
+ bool has = da_is_dir(p) && da_dir_has_entry(p);
+ free(p);
+ if (has)
+ return true;
+ }
+ return false;
+}
+
+/* ------------------------------------------------------------------ */
+/* Accumulator helpers */
+/* ------------------------------------------------------------------ */
+
+static inline void da_warn(dotagents *out, const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *msg = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ fm_sl_push(&out->warnings, msg);
+}
+
+static inline char *da_read_optional(const char *path, layer layer,
+ dotagents *out) {
+ char *raw = NULL;
+ size_t len = 0;
+ if (da_read_file(path, &raw, &len) != 0) {
+ struct stat st;
+ if (stat(path, &st) == 0) {
+ /* exists but unreadable */
+ const char *base = strrchr(path, '/');
+ da_warn(out, "%s: %s: read error", layer_display(layer),
+ base ? base + 1 : path);
+ }
+ free(raw);
+ return NULL;
+ }
+ /* whitespace-only: treat as absent */
+ size_t i = 0;
+ while (i < len && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == '\n' ||
+ raw[i] == '\r'))
+ i++;
+ if (i == len) {
+ free(raw);
+ return NULL;
+ }
+ return raw;
+}
+
+/* ------------------------------------------------------------------ */
+/* Ingest */
+/* ------------------------------------------------------------------ */
+
+static inline void da_ingest_models_json(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/models.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ /* unknown field check (deny_unknown_fields) */
+ bool ok = true;
+ for (size_t i = 0; i < o->count; i++) {
+ const char *k = o->items[i] ? o->items[i]->key : NULL;
+ if (!k || (strcmp(k, "default") != 0 && strcmp(k, "presets") != 0)) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) {
+ const fm_json *def = fm_json_get(o, "default");
+ if (def && def->type == FMJ_STRING) {
+ free(out->models.default_name);
+ out->models.default_name = fm_strdup(def->str);
+ }
+ const fm_json *pres = fm_json_get(o, "presets");
+ if (pres && pres->type == FMJ_OBJECT) {
+ for (size_t pi = 0; pi < pres->count; pi++) {
+ const fm_json *pv = pres->items[pi];
+ if (!pv || pv->type != FMJ_OBJECT) {
+ da_warn(out, "%s: models.json: preset `%s` is not an "
+ "object; skipped",
+ layer_short(layer), pv && pv->key ? pv->key : "?");
+ continue;
+ }
+ const fm_json *bad = NULL;
+ for (size_t fi = 0; fi < pv->count; fi++) {
+ const char *fk = pv->items[fi] ? pv->items[fi]->key : NULL;
+ if (!fk ||
+ (strcmp(fk, "provider") != 0 &&
+ strcmp(fk, "model") != 0 &&
+ strcmp(fk, "base_url") != 0 &&
+ strcmp(fk, "api_key") != 0 &&
+ strcmp(fk, "max_turns") != 0 &&
+ strcmp(fk, "context_limit") != 0)) {
+ bad = pv->items[fi];
+ break;
+ }
+ }
+ if (bad) {
+ da_warn(out, "%s: models.json: preset `%s` has unknown "
+ "field `%s`; skipped",
+ layer_short(layer), pv->key ? pv->key : "?",
+ bad->key ? bad->key : "?");
+ continue;
+ }
+ model_preset mp;
+ memset(&mp, 0, sizeof mp);
+ mp.name = fm_strdup(pv->key ? pv->key : "");
+ const fm_json *s;
+ if ((s = fm_json_get(pv, "provider")) && s->type == FMJ_STRING)
+ mp.provider = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "model")) && s->type == FMJ_STRING)
+ mp.model = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "base_url")) && s->type == FMJ_STRING)
+ mp.base_url = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "api_key")) && s->type == FMJ_STRING)
+ mp.api_key = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "max_turns")) &&
+ s->type == FMJ_NUMBER) {
+ mp.max_turns = (unsigned long)s->num;
+ mp.has_max_turns = true;
+ }
+ if ((s = fm_json_get(pv, "context_limit")) &&
+ s->type == FMJ_NUMBER) {
+ mp.context_limit = (unsigned long)s->num;
+ mp.has_context_limit = true;
+ }
+ /* replace by name or append */
+ bool replaced = false;
+ for (size_t i = 0; i < out->models.preset_count; i++) {
+ if (strcmp(out->models.presets[i].name, pv->key) == 0) {
+ free(out->models.presets[i].name);
+ free(out->models.presets[i].provider);
+ free(out->models.presets[i].model);
+ free(out->models.presets[i].base_url);
+ free(out->models.presets[i].api_key);
+ out->models.presets[i] = mp;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->models.presets = (model_preset *)fm_xrealloc(
+ out->models.presets,
+ (out->models.preset_count + 1) *
+ sizeof(model_preset));
+ out->models.presets[out->models.preset_count++] = mp;
+ }
+ out->has_models = true;
+ }
+ }
+ } else {
+ da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer));
+ }
+ fm_json_free(o);
+}
+
+static inline void da_ingest_mcp(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/mcp.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o) {
+ da_warn(out, "%s: mcp.json: invalid JSON; skipped", layer_short(layer));
+ return;
+ }
+ const fm_json *servers = fm_json_get(o, "mcpServers");
+ if (!servers || servers->type != FMJ_OBJECT) {
+ da_warn(out, "%s: mcp.json: missing `mcpServers` object; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ for (size_t i = 0; i < servers->count; i++) {
+ const fm_json *sv = servers->items[i];
+ if (!sv)
+ continue;
+ char *cfg = fm_json_serialize(sv);
+ bool replaced = false;
+ for (size_t j = 0; j < out->mcp_count; j++) {
+ if (strcmp(out->mcp_names[j], sv->key ? sv->key : "") == 0) {
+ free(out->mcp_configs[j]);
+ out->mcp_configs[j] = cfg;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->mcp_names =
+ (char **)fm_xrealloc(out->mcp_names,
+ (out->mcp_count + 1) * sizeof(char *));
+ out->mcp_configs =
+ (char **)fm_xrealloc(out->mcp_configs,
+ (out->mcp_count + 1) * sizeof(char *));
+ out->mcp_names[out->mcp_count] =
+ fm_strdup(sv->key ? sv->key : "");
+ out->mcp_configs[out->mcp_count] = cfg;
+ out->mcp_count++;
+ }
+ }
+ fm_json_free(o);
+}
+
+static inline void da_ingest_settings(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/speakmcp-settings.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: speakmcp-settings.json: invalid JSON; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ out->settings = fm_json_merge_owned(out->settings, o);
+}
+
+static inline void da_ingest_layouts(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/layouts/ui.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: layouts/ui.json: invalid JSON; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ out->layouts = fm_json_merge_owned(out->layouts, o);
+}
+
+static inline void da_ingest_skills(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/skills", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "skill");
+ if (!md) {
+ da_warn(out, "%s: skills/%s: missing skill.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: skills/%s: skill.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *desc = "";
+ if ((v = doc_get(&d, "description")) && v->kind == FM_STR)
+ desc = v->s;
+ bool enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ enabled = v->b;
+
+ skill s;
+ s.id = fm_strdup(id);
+ s.name = fm_strdup(name);
+ s.description = fm_strdup(desc);
+ s.enabled = enabled;
+ s.body = fm_strdup(d.body);
+ s.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->skill_count; j++) {
+ if (strcmp(out->skills[j].id, s.id) == 0) {
+ free(out->skills[j].id);
+ free(out->skills[j].name);
+ free(out->skills[j].description);
+ free(out->skills[j].body);
+ out->skills[j] = s;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->skills = (skill *)fm_xrealloc(
+ out->skills, (out->skill_count + 1) * sizeof(skill));
+ out->skills[out->skill_count++] = s;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ /* keep sorted by id */
+ if (out->skill_count > 1)
+ qsort(out->skills, out->skill_count, sizeof *out->skills,
+ da_cmp_skill_id);
+}
+
+static inline void da_ingest_sub_agents(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/agents", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "agent");
+ if (!md) {
+ da_warn(out, "%s: agents/%s: missing agent.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: agents/%s: agent.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ /* optional config.json next to agent.md */
+ char *config = NULL;
+ char *cp = fm_strfmt("%s/config.json", paths[i]);
+ char *craw = NULL;
+ if (da_read_file(cp, &craw, NULL) == 0) {
+ size_t cl = strlen(craw);
+ size_t ci = 0;
+ while (ci < cl && (craw[ci] == ' ' || craw[ci] == '\t' ||
+ craw[ci] == '\n' || craw[ci] == '\r'))
+ ci++;
+ if (ci < cl) {
+ fm_json *cv = fm_json_parse(craw);
+ if (cv) {
+ config = fm_json_serialize(cv);
+ fm_json_free(cv);
+ } else {
+ da_warn(out, "%s: agents/%s/config.json: invalid JSON; "
+ "skipped",
+ layer_short(layer), names[i]);
+ }
+ }
+ free(craw);
+ }
+ free(cp);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *desc = "";
+ if ((v = doc_get(&d, "description")) && v->kind == FM_STR)
+ desc = v->s;
+ char *role = "delegation-target";
+ if ((v = doc_get(&d, "role")) && v->kind == FM_STR)
+ role = v->s;
+ char *ctype = NULL;
+ if ((v = doc_get(&d, "connection-type")) && v->kind == FM_STR)
+ ctype = v->s;
+ bool enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ enabled = v->b;
+
+ sub_agent a;
+ a.id = fm_strdup(id);
+ a.name = fm_strdup(name);
+ a.description = fm_strdup(desc);
+ a.role = fm_strdup(role);
+ a.connection_type = ctype ? fm_strdup(ctype) : NULL;
+ a.enabled = enabled;
+ a.body = fm_strdup(d.body);
+ a.config = config;
+ a.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->sub_agent_count; j++) {
+ if (strcmp(out->sub_agents[j].id, a.id) == 0) {
+ free(out->sub_agents[j].id);
+ free(out->sub_agents[j].name);
+ free(out->sub_agents[j].description);
+ free(out->sub_agents[j].role);
+ free(out->sub_agents[j].connection_type);
+ free(out->sub_agents[j].body);
+ free(out->sub_agents[j].config);
+ out->sub_agents[j] = a;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->sub_agents =
+ (sub_agent *)fm_xrealloc(out->sub_agents,
+ (out->sub_agent_count + 1) *
+ sizeof(sub_agent));
+ out->sub_agents[out->sub_agent_count++] = a;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ if (out->sub_agent_count > 1)
+ qsort(out->sub_agents, out->sub_agent_count, sizeof *out->sub_agents,
+ da_cmp_subagent_id);
+}
+
+static inline void da_ingest_tasks(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/tasks", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "task");
+ if (!md) {
+ da_warn(out, "%s: tasks/%s: missing task.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: tasks/%s: task.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *pid = NULL;
+ if ((v = doc_get(&d, "profileId")) && v->kind == FM_STR && v->s &&
+ *v->s)
+ pid = v->s;
+
+ task_entry t;
+ memset(&t, 0, sizeof t);
+ t.id = fm_strdup(id);
+ t.name = fm_strdup(name);
+ t.profile_id = pid ? fm_strdup(pid) : NULL;
+ if ((v = doc_get(&d, "intervalMinutes")) && v->kind == FM_INT &&
+ v->i >= 0) {
+ t.interval_minutes = (unsigned long)v->i;
+ t.has_interval = true;
+ }
+ t.enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ t.enabled = v->b;
+ t.run_on_startup = false;
+ if ((v = doc_get(&d, "runOnStartup")) && v->kind == FM_BOOL)
+ t.run_on_startup = v->b;
+ t.body = fm_strdup(d.body);
+ t.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->task_count; j++) {
+ if (strcmp(out->tasks[j].id, t.id) == 0) {
+ free(out->tasks[j].id);
+ free(out->tasks[j].name);
+ free(out->tasks[j].profile_id);
+ free(out->tasks[j].body);
+ out->tasks[j] = t;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->tasks =
+ (task_entry *)fm_xrealloc(out->tasks,
+ (out->task_count + 1) *
+ sizeof(task_entry));
+ out->tasks[out->task_count++] = t;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ if (out->task_count > 1)
+ qsort(out->tasks, out->task_count, sizeof *out->tasks,
+ da_cmp_task_id);
+}
+
+static inline void da_ingest_memories(const char *root, layer layer,
+ dotagents *out) {
+ char *dir = fm_strfmt("%s/memories", root);
+ DIR *d = opendir(dir);
+ if (!d) {
+ free(dir);
+ return;
+ }
+ /* collect .md files */
+ size_t cap = 8, n = 0;
+ char **files = (char **)fm_xmalloc(cap * sizeof(char *));
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ char *p = fm_strfmt("%s/%s", dir, e->d_name);
+ if (!da_is_file(p)) {
+ free(p);
+ continue;
+ }
+ const char *dot = strrchr(e->d_name, '.');
+ if (!dot || strcasecmp(dot, ".md") != 0) {
+ free(p);
+ continue;
+ }
+ if (n + 1 > cap) {
+ cap *= 2;
+ files = (char **)fm_xrealloc(files, cap * sizeof(char *));
+ }
+ files[n++] = p;
+ }
+ closedir(d);
+ if (n > 1)
+ qsort(files, n, sizeof *files, da_cmp_cstr);
+ for (size_t i = 0; i < n; i++) {
+ const char *base = strrchr(files[i], '/');
+ base = base ? base + 1 : files[i];
+ const char *dot = strrchr(base, '.');
+ char *stem = fm_strndup(
+ base, dot ? (size_t)(dot - base) : strlen(base));
+ char *text = NULL;
+ if (da_read_file(files[i], &text, NULL) != 0) {
+ da_warn(out, "%s: memories/%s.md: unreadable; skipped",
+ layer_short(layer), stem);
+ free(stem);
+ continue;
+ }
+ doc d2;
+ parse_frontmatter(text, &d2);
+ free(text);
+
+ char *id = stem;
+ const fm_value *v;
+ if ((v = doc_get(&d2, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *title = stem;
+ if ((v = doc_get(&d2, "title")) && v->kind == FM_STR)
+ title = v->s;
+ char *importance = "medium";
+ if ((v = doc_get(&d2, "importance")) && v->kind == FM_STR)
+ importance = v->s;
+ char *summary = "";
+ if ((v = doc_get(&d2, "content")) && v->kind == FM_STR)
+ summary = v->s;
+
+ memory m;
+ memset(&m, 0, sizeof m);
+ m.id = fm_strdup(id);
+ m.title = fm_strdup(title);
+ m.importance = fm_strdup(importance);
+ m.summary = fm_strdup(summary);
+ m.body = fm_strdup(d2.body);
+ m.layer = layer;
+ if ((v = doc_get(&d2, "tags"))) {
+ fm_strlist items;
+ fm_list_items(v, &items);
+ m.tags = (char **)fm_xmalloc(
+ (items.len ? items.len : 1) * sizeof(char *));
+ for (size_t k = 0; k < items.len; k++)
+ m.tags[k] = fm_strdup(items.items[k]);
+ m.tag_count = items.len;
+ fm_sl_free(&items);
+ }
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->memory_count; j++) {
+ if (strcmp(out->memories[j].id, m.id) == 0) {
+ free(out->memories[j].id);
+ free(out->memories[j].title);
+ free(out->memories[j].importance);
+ free(out->memories[j].summary);
+ free(out->memories[j].body);
+ for (size_t k = 0; k < out->memories[j].tag_count; k++)
+ free(out->memories[j].tags[k]);
+ free(out->memories[j].tags);
+ out->memories[j] = m;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->memories =
+ (memory *)fm_xrealloc(out->memories,
+ (out->memory_count + 1) *
+ sizeof(memory));
+ out->memories[out->memory_count++] = m;
+ }
+ doc_free(&d2);
+ free(stem);
+ }
+ for (size_t i = 0; i < n; i++)
+ free(files[i]);
+ free(files);
+ free(dir);
+ if (out->memory_count > 1)
+ qsort(out->memories, out->memory_count, sizeof *out->memories,
+ da_cmp_memory_id);
+}
+
+static inline void da_ingest(const char *root, layer layer, dotagents *out) {
+ char *agents_md = da_find_md(root, "agents");
+ if (agents_md) {
+ char *text = da_read_optional(agents_md, layer, out);
+ if (text) {
+ free(out->agents_md);
+ out->agents_md = text;
+ }
+ free(agents_md);
+ }
+ char *sp = da_find_md(root, "system-prompt");
+ if (sp) {
+ char *text = da_read_optional(sp, layer, out);
+ if (text) {
+ free(out->system_prompt);
+ out->system_prompt = text;
+ }
+ free(sp);
+ }
+ da_ingest_models_json(root, layer, out);
+ da_ingest_mcp(root, layer, out);
+ da_ingest_settings(root, layer, out);
+ da_ingest_layouts(root, layer, out);
+ da_ingest_skills(root, layer, out);
+ da_ingest_sub_agents(root, layer, out);
+ da_ingest_tasks(root, layer, out);
+ da_ingest_memories(root, layer, out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Public API */
+/* ------------------------------------------------------------------ */
+
+static inline void dotagents_free(dotagents *d) {
+ free(d->found_layers);
+ for (size_t i = 0; i < d->found_count; i++)
+ free(d->found_paths[i]);
+ free(d->found_paths);
+ fm_sl_free(&d->warnings);
+ free(d->system_prompt);
+ free(d->agents_md);
+ free(d->settings_json);
+ free(d->layouts_json);
+ fm_json_free(d->settings);
+ fm_json_free(d->layouts);
+ free(d->models.default_name);
+ for (size_t i = 0; i < d->models.preset_count; i++) {
+ free(d->models.presets[i].name);
+ free(d->models.presets[i].provider);
+ free(d->models.presets[i].model);
+ free(d->models.presets[i].base_url);
+ free(d->models.presets[i].api_key);
+ }
+ free(d->models.presets);
+ for (size_t i = 0; i < d->mcp_count; i++) {
+ free(d->mcp_names[i]);
+ free(d->mcp_configs[i]);
+ }
+ free(d->mcp_names);
+ free(d->mcp_configs);
+ for (size_t i = 0; i < d->skill_count; i++) {
+ free(d->skills[i].id);
+ free(d->skills[i].name);
+ free(d->skills[i].description);
+ free(d->skills[i].body);
+ }
+ free(d->skills);
+ for (size_t i = 0; i < d->sub_agent_count; i++) {
+ free(d->sub_agents[i].id);
+ free(d->sub_agents[i].name);
+ free(d->sub_agents[i].description);
+ free(d->sub_agents[i].role);
+ free(d->sub_agents[i].connection_type);
+ free(d->sub_agents[i].body);
+ free(d->sub_agents[i].config);
+ }
+ free(d->sub_agents);
+ for (size_t i = 0; i < d->task_count; i++) {
+ free(d->tasks[i].id);
+ free(d->tasks[i].name);
+ free(d->tasks[i].profile_id);
+ free(d->tasks[i].body);
+ }
+ free(d->tasks);
+ for (size_t i = 0; i < d->memory_count; i++) {
+ free(d->memories[i].id);
+ free(d->memories[i].title);
+ free(d->memories[i].importance);
+ for (size_t k = 0; k < d->memories[i].tag_count; k++)
+ free(d->memories[i].tags[k]);
+ free(d->memories[i].tags);
+ free(d->memories[i].summary);
+ free(d->memories[i].body);
+ }
+ free(d->memories);
+}
+
+static inline void dotagents_load_from(dotagents *out, const char *workspace,
+ const char *global) {
+ memset(out, 0, sizeof *out);
+ fm_sl_init(&out->warnings);
+ if (global && da_is_dir(global) && da_has_protocol_content(global)) {
+ da_ingest(global, LAYER_GLOBAL, out);
+ out->found_layers = (layer *)fm_xrealloc(
+ out->found_layers, (out->found_count + 1) * sizeof(layer));
+ out->found_paths = (char **)fm_xrealloc(
+ out->found_paths, (out->found_count + 1) * sizeof(char *));
+ out->found_layers[out->found_count] = LAYER_GLOBAL;
+ out->found_paths[out->found_count] = fm_strdup(global);
+ out->found_count++;
+ }
+ if (da_is_dir(workspace) && da_has_protocol_content(workspace)) {
+ da_ingest(workspace, LAYER_WORKSPACE, out);
+ out->found_layers = (layer *)fm_xrealloc(
+ out->found_layers, (out->found_count + 1) * sizeof(layer));
+ out->found_paths = (char **)fm_xrealloc(
+ out->found_paths, (out->found_count + 1) * sizeof(char *));
+ out->found_layers[out->found_count] = LAYER_WORKSPACE;
+ out->found_paths[out->found_count] = fm_strdup(workspace);
+ out->found_count++;
+ }
+ /* materialize the transient merge trees as stable JSON text */
+ if (out->settings) {
+ out->settings_json = fm_json_serialize(out->settings);
+ fm_json_free(out->settings);
+ out->settings = NULL;
+ }
+ if (out->layouts) {
+ out->layouts_json = fm_json_serialize(out->layouts);
+ fm_json_free(out->layouts);
+ out->layouts = NULL;
+ }
+}
+
+static inline void dotagents_load(dotagents *out) {
+ memset(out, 0, sizeof *out);
+ fm_sl_init(&out->warnings);
+ const char *home = getenv("HOME");
+ char *global = NULL;
+ if (home)
+ global = fm_strfmt("%s/.agents", home);
+ dotagents_load_from(out, ".agents", global);
+ free(global);
+}
+
+/* Sections appended to the preamble: guidelines + skill index + memories. */
+static inline char *dotagents_project_sections(const dotagents *d,
+ unsigned long cap) {
+ if (cap < 1)
+ cap = 1;
+ fm_buf out;
+ fm_buf_init(&out);
+ if (d->agents_md && d->agents_md[0] &&
+ d->agents_md[strcspn(d->agents_md, " \t\n\r")] != 0) {
+ fm_buf_push(&out, '\n');
+ /* trim end */
+ size_t l = strlen(d->agents_md);
+ while (l && (d->agents_md[l - 1] == '\n' || d->agents_md[l - 1] == '\r' ||
+ d->agents_md[l - 1] == ' '))
+ l--;
+ fm_buf_putn(&out, d->agents_md, l);
+ fm_buf_push(&out, '\n');
+ }
+ /* enabled skills */
+ size_t enabled_count = 0;
+ for (size_t i = 0; i < d->skill_count; i++)
+ if (d->skills[i].enabled)
+ enabled_count++;
+ if (enabled_count > 0) {
+ fm_buf_put(&out, "\n# Skills\n\n");
+ fm_buf_put(
+ &out,
+ "Skills available for this project. Load a skill's full "
+ "instructions with the `use_skill` tool (pass its id):\n");
+ size_t listed = 0;
+ for (size_t i = 0; i < d->skill_count && listed < cap; i++) {
+ if (!d->skills[i].enabled)
+ continue;
+ if (d->skills[i].description[0])
+ fm_buf_fmt(&out, "- %s: %s\n", d->skills[i].id,
+ d->skills[i].description);
+ else
+ fm_buf_fmt(&out, "- %s\n", d->skills[i].id);
+ listed++;
+ }
+ if (enabled_count > cap)
+ fm_buf_fmt(&out, "…and %lu more\n", enabled_count - cap);
+ }
+ if (d->memory_count > 0) {
+ fm_buf_fmt(
+ &out,
+ "\n# Memories\n\n%lu persistent memory entries are available "
+ "across sessions. Retrieve them with the `search_memories` tool "
+ "(keyword or tag query).\n",
+ d->memory_count);
+ }
+ return fm_buf_steal(&out);
+}
+
+static inline char *dotagents_compose_preamble(const dotagents *d,
+ const char *base,
+ unsigned long cap) {
+ char *sections = dotagents_project_sections(d, cap);
+ fm_buf b;
+ fm_buf_init(&b);
+ fm_buf_put(&b, base);
+ fm_buf_put(&b, sections);
+ free(sections);
+ return fm_buf_steal(&b);
+}
+
+static inline const sub_agent *dotagents_enabled_sub_agent(
+ const dotagents *d, const char *id) {
+ for (size_t i = 0; i < d->sub_agent_count; i++)
+ if (strcmp(d->sub_agents[i].id, id) == 0 && d->sub_agents[i].enabled)
+ return &d->sub_agents[i];
+ return NULL;
+}
+
+static inline const skill *dotagents_find_skill(const dotagents *d,
+ const char *id) {
+ for (size_t i = 0; i < d->skill_count; i++)
+ if (strcmp(d->skills[i].id, id) == 0)
+ return &d->skills[i];
+ return NULL;
+}
+
+static inline const char *dotagents_mcp_config(const dotagents *d,
+ const char *name) {
+ for (size_t i = 0; i < d->mcp_count; i++)
+ if (strcmp(d->mcp_names[i], name) == 0)
+ return d->mcp_configs[i];
+ return NULL;
+}
+
+static inline const model_preset *models_file_get(const models_file *m,
+ const char *name) {
+ for (size_t i = 0; i < m->preset_count; i++)
+ if (strcmp(m->presets[i].name, name) == 0)
+ return &m->presets[i];
+ return NULL;
+}
+
+static inline void models_file_free(models_file *m) {
+ free(m->default_name);
+ for (size_t i = 0; i < m->preset_count; i++) {
+ free(m->presets[i].name);
+ free(m->presets[i].provider);
+ free(m->presets[i].model);
+ free(m->presets[i].base_url);
+ free(m->presets[i].api_key);
+ }
+ free(m->presets);
+ m->presets = NULL;
+ m->preset_count = 0;
+}
+
+static inline int memory_importance_rank(const memory *m) {
+ fm_buf l;
+ fm_buf_init(&l);
+ for (const char *p = m->importance; *p; p++)
+ fm_buf_push(&l, (char)tolower((unsigned char)*p));
+ l.data[l.len] = '\0';
+ int r = 2;
+ if (strcmp(l.data, "high") == 0)
+ r = 0;
+ else if (strcmp(l.data, "medium") == 0 || strcmp(l.data, "normal") == 0)
+ r = 1;
+ fm_buf_free(&l);
+ return r;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* DOTAGENTS_H */
diff --git a/include/escape.h b/include/escape.h
index db46694..af616b3 100755
--- a/include/escape.h
+++ b/include/escape.h
@@ -37,10 +37,16 @@ void esc_fcursor_right(FILE *, u32 n);
void esc_cursor_line_next(u32 n);
void esc_fcursor_line_next(FILE *, u32 n);
+#define ESC_CURSOR_PREVIOUS(n) CSI #n "F"
+void esc_cursor_previous(u32 n);
+void esc_fcursor_previous(FILE *, u32 n);
+
#define ESC_CURSOR_POSITION(col, row) CSI #row ";" #col "H"
void esc_cursor_position(u32 col, u32 row);
void esc_fcursor_position(FILE *, u32 col, u32 row);
+#define ESC_CURSOR_COLUMN_HOME CSI "G"
+
#define ESC_CLEAR_SCREEN CSI "2J"
void esc_clear_screen(void);
void esc_fclear_screen(FILE *);
@@ -77,6 +83,8 @@ void esc_fscroll_down(FILE *, u32 n);
#define ESC_RESET CSI "0" ESC_SGR
#define ESC_BOLD CSI "1" ESC_SGR
+#define ESC_DIM CSI "2" ESC_SGR
+#define ESC_ITALIC CSI "3" ESC_SGR
#define ESC_UNDERLINE CSI "4" ESC_SGR
#define ESC_BLINK CSI "5" ESC_SGR
#define ESC_REVERSE CSI "7" ESC_SGR
@@ -106,6 +114,60 @@ void esc_fcolor_bg_256(FILE *, u32 n);
#define ESC_COLOR_FG_BRIGHT(n) CSI "9" #n ESC_SGR
#define ESC_COLOR_BG_BRIGHT(n) CSI "10" #n ESC_SGR
+/* Named standard colors (ANSI 0-7). */
+#define ESC_COLOR_BLACK ESC_COLOR_FG(0)
+#define ESC_COLOR_RED ESC_COLOR_FG(1)
+#define ESC_COLOR_GREEN ESC_COLOR_FG(2)
+#define ESC_COLOR_YELLOW ESC_COLOR_FG(3)
+#define ESC_COLOR_BLUE ESC_COLOR_FG(4)
+#define ESC_COLOR_MAGENTA ESC_COLOR_FG(5)
+#define ESC_COLOR_CYAN ESC_COLOR_FG(6)
+#define ESC_COLOR_WHITE ESC_COLOR_FG(7)
+
+#define ESC_CURSOR_HIDE CSI "?25l"
+void esc_cursor_hide(void);
+void esc_fcursor_hide(FILE *);
+
+#define ESC_CURSOR_SHOW CSI "?25h"
+void esc_cursor_show(void);
+void esc_fcursor_show(FILE *);
+
+#define ESC_CURSOR_SAVE CSI "s"
+void esc_cursor_save(void);
+void esc_fcursor_save(FILE *);
+
+#define ESC_CURSOR_RESTORE CSI "u"
+void esc_cursor_restore(void);
+void esc_fcursor_restore(FILE *);
+
+#define ESC_BELL "\x07"
+void esc_bell(void);
+void esc_fbell(FILE *);
+
+#define ESC_ALT_SCREEN_ON CSI "?1049h"
+void esc_alt_screen_on(void);
+void esc_falt_screen_on(FILE *);
+
+#define ESC_ALT_SCREEN_OFF CSI "?1049l"
+void esc_alt_screen_off(void);
+void esc_falt_screen_off(FILE *);
+
+#define ESC_BRACKETED_PASTE_ON CSI "?2004h"
+void esc_bracketed_paste_on(void);
+void esc_fbracketed_paste_on(FILE *);
+
+#define ESC_BRACKETED_PASTE_OFF CSI "?2004l"
+void esc_bracketed_paste_off(void);
+void esc_fbracketed_paste_off(FILE *);
+
+#define ESC_KITTY_ON CSI ">1u"
+void esc_kitty_on(void);
+void esc_fkitty_on(FILE *);
+
+#define ESC_KITTY_OFF CSI ">0u"
+void esc_kitty_off(void);
+void esc_fkitty_off(FILE *);
+
#ifdef __cplusplus
}
#endif
@@ -121,6 +183,7 @@ void esc_fcursor_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uB", (u
void esc_fcursor_left(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uD", (unsigned)n); }
void esc_fcursor_right(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uC", (unsigned)n); }
void esc_fcursor_line_next(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uE", (unsigned)n); }
+void esc_fcursor_previous(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uF", (unsigned)n); }
void esc_fcursor_position(FILE *f, u32 col, u32 row) {
if (!f) return;
fprintf(f, CSI "%u;%uH", (unsigned)row, (unsigned)col);
@@ -151,11 +214,24 @@ void esc_fcolor_bg_256(FILE *f, u32 n) {
fprintf(f, CSI "48;5;%u" ESC_SGR, (unsigned)n);
}
+void esc_fcursor_hide(FILE *f) { if (!f) return; fputs(CSI "?25l", f); }
+void esc_fcursor_show(FILE *f) { if (!f) return; fputs(CSI "?25h", f); }
+void esc_fcursor_save(FILE *f) { if (!f) return; fputs(CSI "s", f); }
+void esc_fcursor_restore(FILE *f) { if (!f) return; fputs(CSI "u", f); }
+void esc_fbell(FILE *f) { if (!f) return; fputc('\a', f); }
+void esc_falt_screen_on(FILE *f) { if (!f) return; fputs(CSI "?1049h", f); }
+void esc_falt_screen_off(FILE *f) { if (!f) return; fputs(CSI "?1049l", f); }
+void esc_fbracketed_paste_on(FILE *f) { if (!f) return; fputs(CSI "?2004h", f); }
+void esc_fbracketed_paste_off(FILE *f) { if (!f) return; fputs(CSI "?2004l", f); }
+void esc_fkitty_on(FILE *f) { if (!f) return; fputs(CSI ">1u", f); }
+void esc_fkitty_off(FILE *f) { if (!f) return; fputs(CSI ">0u", f); }
+
void esc_cursor_up(u32 n) { esc_fcursor_up(stdout, n); }
void esc_cursor_down(u32 n) { esc_fcursor_down(stdout, n); }
void esc_cursor_left(u32 n) { esc_fcursor_left(stdout, n); }
void esc_cursor_right(u32 n) { esc_fcursor_right(stdout, n); }
void esc_cursor_line_next(u32 n) { esc_fcursor_line_next(stdout, n); }
+void esc_cursor_previous(u32 n) { esc_fcursor_previous(stdout, n); }
void esc_cursor_position(u32 col, u32 row) {
esc_fcursor_position(stdout, col, row);
}
@@ -181,4 +257,16 @@ void esc_color_bg_256(u32 n) {
esc_fcolor_bg_256(stdout, n);
}
+void esc_cursor_hide(void) { esc_fcursor_hide(stdout); }
+void esc_cursor_show(void) { esc_fcursor_show(stdout); }
+void esc_cursor_save(void) { esc_fcursor_save(stdout); }
+void esc_cursor_restore(void) { esc_fcursor_restore(stdout); }
+void esc_bell(void) { esc_fbell(stdout); }
+void esc_alt_screen_on(void) { esc_falt_screen_on(stdout); }
+void esc_alt_screen_off(void) { esc_falt_screen_off(stdout); }
+void esc_bracketed_paste_on(void) { esc_fbracketed_paste_on(stdout); }
+void esc_bracketed_paste_off(void) { esc_fbracketed_paste_off(stdout); }
+void esc_kitty_on(void) { esc_fkitty_on(stdout); }
+void esc_kitty_off(void) { esc_fkitty_off(stdout); }
+
#endif // ESCAPE_IMPLEMENTATION \ No newline at end of file
diff --git a/include/frontmatter.h b/include/frontmatter.h
new file mode 100644
index 0000000..56c6935
--- /dev/null
+++ b/include/frontmatter.h
@@ -0,0 +1,1058 @@
+/* frontmatter.h — portable, header-only markdown frontmatter library.
+ *
+ * Parses a markdown document with an optional `---` fenced frontmatter block
+ * into typed values. Zero dependencies: libc only. Not full YAML: simple
+ * `key: value` lines. Values may be quoted (`"..."` or `'...'`). List fields
+ * accept CSV (`tags: a, b, c`) or JSON arrays (`tags: ["a", "b"]`).
+ *
+ * Format:
+ * - Frontmatter is delimited by a leading `---` line and a closing `---`
+ * line. An unterminated fence (no closing `---`) means the whole text is
+ * body — no metadata.
+ * - Lines are `key: value`. Blank lines, `#` comments, and lines without a
+ * colon are skipped. Keys and values are trimmed.
+ * - Value types: `true`/`false` → bool; bare integer → int; `[...]` → list
+ * (JSON array; non-string elements are serialized); anything else →
+ * string (quotes stripped if present).
+ * - The body is everything after the closing fence, trailing whitespace
+ * trimmed. Documents without frontmatter: whole text, trailing
+ * whitespace trimmed.
+ *
+ * Usage: #include "frontmatter.h" in your code. The header is complete on
+ * its own; all functions are static inline, safe to include in any number
+ * of translation units.
+ *
+ * API:
+ * parse_frontmatter(text, &doc) split frontmatter + body
+ * doc_get(&doc, "key") value or NULL
+ * doc_free(&doc) free the document
+ * fm_value_free(&val) free one value
+ * fm_list_items(&val, &items) list-valued field as string items
+ * fm_json_parse(text) embedded JSON value or NULL (invalid)
+ * fm_json_free(v) / fm_json_get(obj, key) / fm_json_serialize(v)
+ * fm_json_merge_owned(base, over) shallow object merge; owns both inputs
+ *
+ * Concurrency: no shared state. Each call is self-contained.
+ */
+#ifndef FRONTMATTER_H
+#define FRONTMATTER_H
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Allocation */
+/* ------------------------------------------------------------------ */
+
+static inline void *fm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *fm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *fm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)fm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_strndup(const char *s, size_t n) {
+ char *p = (char *)fm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_vsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return fm_strdup("");
+ char *p = (char *)fm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *fm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* Growable byte buffer (NUL-terminated on steal) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} fm_buf;
+
+static inline void fm_buf_init(fm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_free(fm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_reset(fm_buf *b) {
+ b->len = 0;
+}
+
+static inline void fm_buf_reserve(fm_buf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *)fm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void fm_buf_putn(fm_buf *b, const char *s, size_t n) {
+ fm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void fm_buf_put(fm_buf *b, const char *s) {
+ if (s)
+ fm_buf_putn(b, s, strlen(s));
+}
+
+static inline void fm_buf_push(fm_buf *b, char c) {
+ fm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline int fm_buf_fmt(fm_buf *b, const char *fmt, ...) {
+ va_list ap, ap2;
+ if (!fmt)
+ return 0;
+ va_start(ap, fmt);
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0) {
+ va_end(ap);
+ return -1;
+ }
+ fm_buf_reserve(b, (size_t)n);
+ vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap);
+ va_end(ap);
+ b->len += (size_t)n;
+ return n;
+}
+
+static inline char *fm_buf_steal(fm_buf *b) {
+ if (!b->data)
+ b->data = (char *)fm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* String list */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} fm_strlist;
+
+static inline void fm_sl_init(fm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void fm_sl_free(fm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ fm_sl_init(l);
+}
+
+static inline void fm_sl_push(fm_strlist *l, char *owned) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items = (char **)fm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = owned;
+}
+
+static inline void fm_sl_push_copy(fm_strlist *l, const char *s) {
+ fm_sl_push(l, fm_strdup(s));
+}
+
+static inline void fm_sl_pop(fm_strlist *l) {
+ if (!l->len)
+ return;
+ free(l->items[--l->len]);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON values */
+/* Object, array, string, number, bool, null. Parse, serialize, */
+/* lookup, shallow-merge. */
+/* ------------------------------------------------------------------ */
+
+enum {
+ FMJ_OBJECT,
+ FMJ_ARRAY,
+ FMJ_STRING,
+ FMJ_NUMBER,
+ FMJ_BOOL,
+ FMJ_NULL,
+};
+
+typedef struct fm_json {
+ int type;
+ char *key; /* OBJECT member key (owned), else NULL */
+ char *str; /* FMJ_STRING value (owned), else NULL */
+ double num; /* FMJ_NUMBER */
+ int is_int; /* FMJ_NUMBER was an integer literal in range */
+ long long i; /* FMJ_NUMBER integral value */
+ int b; /* FMJ_BOOL */
+ struct fm_json **items; /* OBJECT/ARRAY members (owned) */
+ size_t count;
+} fm_json;
+
+static inline fm_json *fm_json_new(int type) {
+ fm_json *v = (fm_json *)fm_xmalloc(sizeof *v);
+ memset(v, 0, sizeof *v);
+ v->type = type;
+ return v;
+}
+
+static inline void fm_json_free(fm_json *v) {
+ if (!v)
+ return;
+ for (size_t i = 0; i < v->count; i++)
+ fm_json_free(v->items[i]);
+ free(v->items);
+ free(v->key);
+ free(v->str);
+ free(v);
+}
+
+static inline const fm_json *fm_json_get(const fm_json *o, const char *key) {
+ if (!o || o->type != FMJ_OBJECT || !key)
+ return NULL;
+ for (size_t i = 0; i < o->count; i++)
+ if (o->items[i] && o->items[i]->key &&
+ strcmp(o->items[i]->key, key) == 0)
+ return o->items[i];
+ return NULL;
+}
+
+/* Max nesting depth for the recursive-descent JSON parser; bounds stack
+ * growth so a pathologically deep input (e.g. 100k nested arrays) is
+ * rejected with an error instead of overflowing the C stack. */
+#define FM_JSON_MAX_DEPTH 256
+
+typedef struct {
+ const char *p;
+ const char *end;
+ int err;
+ size_t depth;
+} fm_jp;
+
+static inline void fm_jskip(fm_jp *s) {
+ while (s->p < s->end && (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' ||
+ *s->p == '\r'))
+ s->p++;
+}
+
+/* String at s->p (opening quote). Returns owned unescaped text. */
+static inline char *fm_jstring_raw(fm_jp *s) {
+ if (s->p >= s->end || *s->p != '"') {
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_buf b;
+ fm_buf_init(&b);
+ while (s->p < s->end && *s->p != '"') {
+ char c = *s->p++;
+ if (c == '\\' && s->p < s->end) {
+ char e = *s->p++;
+ switch (e) {
+ case '"':
+ fm_buf_push(&b, '"');
+ break;
+ case '\\':
+ fm_buf_push(&b, '\\');
+ break;
+ case '/':
+ fm_buf_push(&b, '/');
+ break;
+ case 'b':
+ fm_buf_push(&b, '\b');
+ break;
+ case 'f':
+ fm_buf_push(&b, '\f');
+ break;
+ case 'n':
+ fm_buf_push(&b, '\n');
+ break;
+ case 'r':
+ fm_buf_push(&b, '\r');
+ break;
+ case 't':
+ fm_buf_push(&b, '\t');
+ break;
+ case 'u': {
+ if (s->end - s->p < 4)
+ goto fail;
+ unsigned h = 0;
+ bool ok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ ok = false;
+ break;
+ }
+ h = h * 16 + (unsigned)d;
+ }
+ if (!ok)
+ goto fail;
+ s->p += 4;
+ /* Join a UTF-16 surrogate pair (high \uD800-\uDBFF followed by
+ * low \uDC00-\uDFFF) into a single codepoint so astral chars
+ * (emoji, CJK ext-B, …) emit proper 4-byte UTF-8 rather than
+ * two CESU-8 3-byte sequences. Lone surrogates fall through to
+ * the 3-byte (lenient) encoding below. */
+ unsigned cp = h;
+ if (h >= 0xD800u && h <= 0xDBFFu &&
+ s->end - s->p >= 6 && s->p[0] == '\\' && s->p[1] == 'u') {
+ unsigned lo = 0;
+ bool lok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[2 + k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ lok = false;
+ break;
+ }
+ lo = lo * 16 + (unsigned)d;
+ }
+ if (lok && lo >= 0xDC00u && lo <= 0xDFFFu) {
+ cp = 0x10000u + ((h - 0xD800u) << 10) + (lo - 0xDC00u);
+ s->p += 6;
+ }
+ }
+ if (cp < 0x80) {
+ fm_buf_push(&b, (char)cp);
+ } else if (cp < 0x800) {
+ fm_buf_push(&b, (char)(0xC0 | (cp >> 6)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else if (cp < 0x10000) {
+ fm_buf_push(&b, (char)(0xE0 | (cp >> 12)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else {
+ fm_buf_push(&b, (char)(0xF0 | (cp >> 18)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 12) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ }
+ break;
+ }
+ default:
+ goto fail;
+ }
+ } else if (c < 0x20) {
+ goto fail; /* raw control char: invalid JSON */
+ } else {
+ fm_buf_push(&b, c);
+ }
+ }
+ if (s->p >= s->end || *s->p != '"')
+ goto fail;
+ s->p++;
+ return fm_buf_steal(&b);
+fail:
+ s->err = 1;
+ fm_buf_free(&b);
+ return NULL;
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s);
+
+static inline fm_json *fm_jobject(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* { */
+ fm_json *o = fm_json_new(FMJ_OBJECT);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ char *key = fm_jstring_raw(s);
+ if (!key) {
+ fm_json_free(o);
+ return NULL;
+ }
+ fm_jskip(s);
+ if (s->p >= s->end || *s->p != ':') {
+ free(key);
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ free(key);
+ fm_json_free(o);
+ return NULL;
+ }
+ v->key = key;
+ o->items =
+ (fm_json **)fm_xrealloc(o->items, (o->count + 1) * sizeof(fm_json *));
+ o->items[o->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jarray(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* [ */
+ fm_json *a = fm_json_new(FMJ_ARRAY);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ fm_json_free(a);
+ return NULL;
+ }
+ a->items =
+ (fm_json **)fm_xrealloc(a->items, (a->count + 1) * sizeof(fm_json *));
+ a->items[a->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json_free(a);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s) {
+ fm_jskip(s);
+ if (s->p >= s->end) {
+ s->err = 1;
+ return NULL;
+ }
+ char c = *s->p;
+ if (c == '{')
+ return fm_jobject(s);
+ if (c == '[')
+ return fm_jarray(s);
+ if (c == '"') {
+ char *t = fm_jstring_raw(s);
+ if (!t)
+ return NULL;
+ fm_json *v = fm_json_new(FMJ_STRING);
+ v->str = t;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "true", 4) == 0) {
+ s->p += 4;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 1;
+ return v;
+ }
+ if (s->end - s->p >= 5 && strncmp(s->p, "false", 5) == 0) {
+ s->p += 5;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 0;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "null", 4) == 0) {
+ s->p += 4;
+ return fm_json_new(FMJ_NULL);
+ }
+ if (c == '-' || (c >= '0' && c <= '9')) {
+ const char *start = s->p;
+ if (c == '-')
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ if (*s->p == '0' && s->p + 1 < s->end && s->p[1] >= '0' &&
+ s->p[1] <= '9') {
+ s->err = 1; /* leading zero */
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ int is_int = 1;
+ if (s->p < s->end && *s->p == '.') {
+ is_int = 0;
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ if (s->p < s->end && (*s->p == 'e' || *s->p == 'E')) {
+ is_int = 0;
+ s->p++;
+ if (s->p < s->end && (*s->p == '+' || *s->p == '-'))
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ char *endp = NULL;
+ errno = 0;
+ double d = strtod(start, &endp);
+ if (errno != 0 || endp == start) {
+ s->err = 1;
+ return NULL;
+ }
+ fm_json *v = fm_json_new(FMJ_NUMBER);
+ v->num = d;
+ if (is_int && d >= -9.0e18 && d <= 9.0e18) {
+ v->is_int = 1;
+ v->i = (long long)d;
+ }
+ return v;
+ }
+ s->err = 1;
+ return NULL;
+}
+
+static inline fm_json *fm_json_parse(const char *text) {
+ if (!text)
+ return NULL;
+ fm_jp s = {text, text + strlen(text), 0, 0};
+ fm_json *v = fm_jvalue(&s);
+ if (!v)
+ return NULL;
+ fm_jskip(&s);
+ if (s.p != s.end) { /* trailing garbage */
+ fm_json_free(v);
+ return NULL;
+ }
+ return v;
+}
+
+static inline void fm_jstring_out(fm_buf *b, const char *s) {
+ fm_buf_push(b, '"');
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
+ unsigned char c = *p;
+ switch (c) {
+ case '"':
+ fm_buf_put(b, "\\\"");
+ break;
+ case '\\':
+ fm_buf_put(b, "\\\\");
+ break;
+ case '\b':
+ fm_buf_put(b, "\\b");
+ break;
+ case '\f':
+ fm_buf_put(b, "\\f");
+ break;
+ case '\n':
+ fm_buf_put(b, "\\n");
+ break;
+ case '\r':
+ fm_buf_put(b, "\\r");
+ break;
+ case '\t':
+ fm_buf_put(b, "\\t");
+ break;
+ default:
+ if (c < 0x20)
+ fm_buf_fmt(b, "\\u%04x", c);
+ else
+ fm_buf_push(b, (char)c);
+ }
+ }
+ fm_buf_push(b, '"');
+}
+
+static inline void fm_json_out(const fm_json *v, fm_buf *b) {
+ switch (v->type) {
+ case FMJ_NULL:
+ fm_buf_put(b, "null");
+ break;
+ case FMJ_BOOL:
+ fm_buf_put(b, v->b ? "true" : "false");
+ break;
+ case FMJ_STRING:
+ fm_jstring_out(b, v->str ? v->str : "");
+ break;
+ case FMJ_NUMBER:
+ if (v->is_int)
+ fm_buf_fmt(b, "%lld", v->i);
+ else
+ fm_buf_fmt(b, "%.15g", v->num);
+ break;
+ case FMJ_ARRAY:
+ fm_buf_push(b, '[');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, ']');
+ break;
+ case FMJ_OBJECT:
+ fm_buf_push(b, '{');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_jstring_out(b, v->items[i]->key ? v->items[i]->key : "");
+ fm_buf_put(b, ":");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, '}');
+ break;
+ }
+}
+
+static inline char *fm_json_serialize(const fm_json *v) {
+ fm_buf b;
+ fm_buf_init(&b);
+ if (v)
+ fm_json_out(v, &b);
+ return fm_buf_steal(&b);
+}
+
+/* Shallow merge of two objects: union of keys, `over` wins per key.
+ * Takes ownership of both inputs (either may be NULL or non-object, in
+ * which case it is dropped). Returns a new object; free with fm_json_free.
+ * Member order: over's keys first, then base-only keys. */
+static inline fm_json *fm_json_merge_owned(const fm_json *base,
+ const fm_json *over) {
+ fm_json *B = (base && base->type == FMJ_OBJECT) ? (fm_json *)base : NULL;
+ fm_json *O = (over && over->type == FMJ_OBJECT) ? (fm_json *)over : NULL;
+ if (base && base->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)base);
+ if (over && over->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)over);
+ fm_json *r = fm_json_new(FMJ_OBJECT);
+ if (O) {
+ for (size_t i = 0; i < O->count; i++)
+ if (O->items[i]) {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = O->items[i];
+ }
+ }
+ if (B) {
+ for (size_t i = 0; i < B->count; i++) {
+ fm_json *m = B->items[i];
+ if (!m)
+ continue;
+ if (O && fm_json_get(O, m->key))
+ fm_json_free(m); /* superseded */
+ else {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = m;
+ }
+ }
+ }
+ if (B) {
+ free(B->items);
+ free(B->key);
+ free(B->str);
+ free(B);
+ }
+ if (O) {
+ free(O->items);
+ free(O->key);
+ free(O->str);
+ free(O);
+ }
+ return r;
+}
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+/* Frontmatter value */
+typedef enum {
+ FM_STR,
+ FM_LIST,
+ FM_BOOL,
+ FM_INT,
+} fm_kind;
+
+typedef struct {
+ fm_kind kind;
+ char *s;
+ char **items;
+ size_t item_count;
+ bool b;
+ long long i;
+} fm_value;
+
+static inline void fm_value_free(fm_value *v) {
+ free(v->s);
+ for (size_t i = 0; i < v->item_count; i++)
+ free(v->items[i]);
+ free(v->items);
+ memset(v, 0, sizeof *v);
+}
+
+/* List-valued field as items: JSON array as-is, CSV split on commas,
+ * single string as one item. */
+static inline void fm_list_items(const fm_value *v, fm_strlist *out) {
+ fm_sl_init(out);
+ if (!v)
+ return;
+ if (v->kind == FM_LIST) {
+ for (size_t i = 0; i < v->item_count; i++)
+ fm_sl_push_copy(out, v->items[i]);
+ return;
+ }
+ if (v->kind != FM_STR || !v->s || !strchr(v->s, ',')) {
+ if (v->s) {
+ size_t a = 0, n = strlen(v->s);
+ while (a < n && isspace((unsigned char)v->s[a]))
+ a++;
+ while (n > a && isspace((unsigned char)v->s[n - 1]))
+ n--;
+ if (n > a)
+ fm_sl_push(out, fm_strndup(v->s + a, n - a));
+ }
+ return;
+ }
+ const char *s = v->s;
+ size_t n = strlen(s);
+ size_t start = 0;
+ for (;;) {
+ while (start < n && isspace((unsigned char)s[start]))
+ start++;
+ if (start >= n)
+ break;
+ const char *comma = (const char *)memchr(s + start, ',', n - start);
+ size_t seg_end = comma ? (size_t)(comma - s) : n;
+ size_t a = start, b = seg_end;
+ while (b > a && isspace((unsigned char)s[b - 1]))
+ b--;
+ if (b > a)
+ fm_sl_push(out, fm_strndup(s + a, b - a));
+ if (!comma)
+ break;
+ start = seg_end + 1;
+ }
+}
+
+/* A frontmatter entry */
+typedef struct {
+ char *key;
+ fm_value val;
+} fm_entry;
+
+/* A markdown document with optional `---` frontmatter */
+typedef struct {
+ fm_entry *entries;
+ size_t count;
+ size_t cap;
+ char *body;
+} doc;
+
+static inline const fm_value *doc_get(const doc *d, const char *key) {
+ for (size_t i = 0; i < d->count; i++)
+ if (strcmp(d->entries[i].key, key) == 0)
+ return &d->entries[i].val;
+ return NULL;
+}
+
+static inline void doc_free(doc *d) {
+ for (size_t i = 0; i < d->count; i++) {
+ free(d->entries[i].key);
+ fm_value_free(&d->entries[i].val);
+ }
+ free(d->entries);
+ free(d->body);
+ memset(d, 0, sizeof *d);
+}
+
+static inline void fm_parse_value(const char *raw, fm_value *v) {
+ memset(v, 0, sizeof *v);
+ if (raw[0] == '[') {
+ fm_json *arr = fm_json_parse(raw);
+ if (arr && arr->type == FMJ_ARRAY) {
+ v->kind = FM_LIST;
+ v->items = (char **)fm_xmalloc(arr->count * sizeof(char *));
+ size_t i = 0;
+ for (size_t k = 0; k < arr->count; k++) {
+ const fm_json *it = arr->items[k];
+ if (it && it->type == FMJ_STRING)
+ v->items[i++] = fm_strdup(it->str);
+ else
+ v->items[i++] = it ? fm_json_serialize(it)
+ : fm_strdup("");
+ }
+ v->item_count = arr->count;
+ fm_json_free(arr);
+ return;
+ }
+ fm_json_free(arr);
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+ return;
+ }
+ if (strcmp(raw, "true") == 0) {
+ v->kind = FM_BOOL;
+ v->b = true;
+ return;
+ }
+ if (strcmp(raw, "false") == 0) {
+ v->kind = FM_BOOL;
+ v->b = false;
+ return;
+ }
+ size_t len = strlen(raw);
+ if (len >= 2 &&
+ ((raw[0] == '"' && raw[len - 1] == '"') ||
+ (raw[0] == '\'' && raw[len - 1] == '\''))) {
+ v->kind = FM_STR;
+ v->s = fm_strndup(raw + 1, len - 2);
+ return;
+ }
+ /* integer? */
+ char *end = NULL;
+ errno = 0;
+ long long n = strtoll(raw, &end, 10);
+ if (errno == 0 && end && *end == '\0' && raw[0] != '\0') {
+ v->kind = FM_INT;
+ v->i = n;
+ return;
+ }
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+}
+
+/* Split text into lines (no trailing empty). Returns line count. */
+static inline char **fm_split_lines(const char *text, size_t *out_count) {
+ size_t cap = 16, n = 0;
+ char **lines = (char **)fm_xmalloc(cap * sizeof(char *));
+ const char *p = text;
+ while (*p) {
+ const char *nl = (const char *)strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (n + 1 > cap) {
+ cap *= 2;
+ lines = (char **)fm_xrealloc(lines, cap * sizeof(char *));
+ }
+ lines[n++] = fm_strndup(p, len);
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ *out_count = n;
+ return lines;
+}
+
+static inline void parse_frontmatter(const char *text, doc *out) {
+ memset(out, 0, sizeof *out);
+ size_t nlines = 0;
+ char **lines = fm_split_lines(text, &nlines);
+
+ char *body = fm_strdup(text); /* default: whole text */
+ if (nlines == 0) {
+ /* keep as-is */
+ } else if (strcmp(lines[0], "---") != 0) {
+ /* trim end */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ size_t close = 0;
+ bool found = false;
+ for (size_t i = 1; i < nlines; i++) {
+ if (strcmp(lines[i], "---") == 0) {
+ close = i - 1; /* index within lines[1..] */
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ /* whole text is body, trimmed */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ for (size_t i = 1; i <= close; i++) {
+ const char *line = lines[i];
+ /* trim */
+ size_t start = 0, len = strlen(line);
+ while (start < len && isspace((unsigned char)line[start]))
+ start++;
+ while (len > start &&
+ isspace((unsigned char)line[len - 1]))
+ len--;
+ if (len == 0 || line[start] == '#')
+ continue;
+ const char *colon =
+ (const char *)memchr(line + start, ':', len - start);
+ if (!colon)
+ continue;
+ size_t klen = (size_t)(colon - (line + start));
+ if (klen == 0)
+ continue;
+ char *key = fm_strndup(line + start, klen);
+ const char *vraw = colon + 1;
+ /* trim value */
+ size_t vlen = strlen(vraw);
+ size_t vstart = 0;
+ while (vstart < vlen &&
+ isspace((unsigned char)vraw[vstart]))
+ vstart++;
+ while (vlen > vstart &&
+ isspace((unsigned char)vraw[vlen - 1]))
+ vlen--;
+ fm_value val;
+ char *vtmp = fm_strndup(vraw + vstart, vlen - vstart);
+ fm_parse_value(vtmp, &val);
+ free(vtmp);
+ if (out->count + 1 > out->cap) {
+ out->cap = out->cap ? out->cap * 2 : 8;
+ out->entries =
+ (fm_entry *)fm_xrealloc(out->entries,
+ out->cap *
+ sizeof(out->entries[0]));
+ }
+ out->entries[out->count].key = key;
+ out->entries[out->count].val = val;
+ out->count++;
+ }
+ /* body = lines[2+close..] joined by \n, trimmed end */
+ fm_buf b;
+ fm_buf_init(&b);
+ for (size_t i = 2 + close; i < nlines; i++) {
+ if (i > 2 + close)
+ fm_buf_push(&b, '\n');
+ fm_buf_put(&b, lines[i]);
+ }
+ char *nb = fm_buf_steal(&b);
+ size_t blen = strlen(nb);
+ while (blen && (nb[blen - 1] == '\n' || nb[blen - 1] == '\r' ||
+ nb[blen - 1] == ' '))
+ nb[--blen] = '\0';
+ free(body);
+ body = nb;
+ }
+ }
+ for (size_t i = 0; i < nlines; i++)
+ free(lines[i]);
+ free(lines);
+ out->body = body;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* FRONTMATTER_H */
diff --git a/include/llm.h b/include/llm.h
new file mode 100644
index 0000000..8fc1c7f
--- /dev/null
+++ b/include/llm.h
@@ -0,0 +1,1539 @@
+/*
+ * llm.h -- header-only LLM provider protocol layer.
+ *
+ * Speaks OpenAI-compatible (any /chat/completions endpoint), Anthropic
+ * (/v1/messages), and Ollama (/api/chat). Pure protocol: request assembly,
+ * HTTP POST with stop-cancellation, SSE / NDJSON streaming with live delta
+ * emission, response and error-body parsing, Retry-After handling. No
+ * application state: messages arrive as borrowed `llm_message` arrays, tools
+ * as a pre-built `"tools":[...]` JSON fragment, and tool execution, session
+ * access, and emit formatting are supplied by the caller.
+ *
+ * Header-only: every symbol is `static inline`, so a translation unit that
+ * `#include`s this file gets a self-contained copy and there is nothing to
+ * link. Include it from exactly the TUs that drive a provider. Each TU's
+ * copies are independent.
+ *
+ * Dependencies: curl, cJSON, libc.
+ *
+ * API:
+ * llm_url / llm_auth / llm_build_request request side
+ * llm_post / llm_post_stream transport
+ * llm_parse_body / llm_parse_error /
+ * llm_header_retry_after response side
+ * llm_sse_acc_* / llm_sse_parse_line streaming accumulator
+ * llm_completion / llm_completion_free parsed result
+ */
+#ifndef LLM_H
+#define LLM_H
+
+#include <curl/curl.h>
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Provider + connection */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_OPENAI = 0,
+ LLM_ANTHROPIC,
+ LLM_OLLAMA,
+} llm_provider;
+
+/* One provider connection. Strings are borrowed for the call's lifetime.
+ * base_url NULL = provider default; api_key NULL = unauthenticated;
+ * ca_bundle NULL = curl's default CA path. */
+typedef struct {
+ llm_provider provider;
+ const char *model;
+ const char *base_url;
+ const char *api_key;
+ const char *ca_bundle;
+} llm_conn;
+
+/* ------------------------------------------------------------------ */
+/* Wire message model (borrowed strings) */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_ROLE_SYSTEM,
+ LLM_ROLE_USER,
+ LLM_ROLE_ASSISTANT,
+} llm_role;
+
+typedef enum {
+ LLM_TEXT, /* user/assistant text */
+ LLM_REASONING, /* assistant chain of thought (display only, not re-sent) */
+ LLM_TOOL_CALL, /* assistant: name, args (JSON text) */
+ LLM_TOOL_RESULT, /* user: call (tool-call id), name, text */
+} llm_part_kind;
+
+typedef struct {
+ llm_part_kind kind;
+ const char *text; /* LLM_TEXT / LLM_REASONING / LLM_TOOL_RESULT */
+ const char *name; /* LLM_TOOL_CALL / LLM_TOOL_RESULT */
+ const char *args; /* LLM_TOOL_CALL: JSON text */
+ const char *call; /* LLM_TOOL_RESULT: tool-call id (may be NULL) */
+} llm_part;
+
+typedef struct {
+ llm_role role;
+ llm_part *parts; /* borrowed; const from the consumer's view */
+ size_t part_count;
+} llm_message;
+
+/* Growable byte buffer (NUL-terminated on steal). */
+typedef struct llm_buf {
+ char *data;
+ size_t len;
+ size_t cap;
+} llm_buf;
+
+/* ------------------------------------------------------------------ */
+/* Parsed completion (owned strings) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *text;
+ char *reasoning; /* thinking / reasoning_content, when the model returns it */
+ char **tc_id;
+ char **tc_name;
+ char **tc_args;
+ size_t tc_len;
+ unsigned long input_tokens;
+} llm_completion;
+
+/* Accumulates one streamed response. Text/reasoning arrive as deltas;
+ * tool calls may arrive in indexed chunks (OpenAI) or as whole blocks
+ * (Anthropic) or complete objects (Ollama NDJSON). */
+typedef struct {
+ struct llm_buf text;
+ struct llm_buf reasoning;
+ char **tc_id;
+ char **tc_name;
+ struct llm_buf *tc_args; /* growing argument fragments, one per call */
+ size_t tc_len;
+ unsigned long input_tokens;
+ bool done;
+ /* Anthropic event state: the `event:` line that the next `data:`
+ * belongs to, and the kind of the open content block. */
+ char *anth_event;
+ int anth_mode; /* 0 = text, 1 = thinking, 2 = tool */
+ size_t anth_tc;
+} llm_sse_acc;
+
+/* Live-delta emitter for streaming. Return 0 to continue, non-zero to
+ * abort the transfer (a user stop). */
+typedef enum {
+ LLM_EMIT_TEXT = 0,
+ LLM_EMIT_REASONING,
+} llm_emit_kind;
+
+typedef int (*llm_emit_cb)(llm_provider prov, llm_emit_kind kind,
+ const char *delta, void *user);
+
+/* ================================================================== */
+/* Implementation (static inline; active in every including TU) */
+/* ================================================================== */
+
+/* ------------------------------------------------------------------ */
+/* Allocation + small string utilities (self-contained) */
+/* ------------------------------------------------------------------ */
+
+static inline void *llm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "llm: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *llm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "llm: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *llm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)llm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *llm_strndup(const char *s, size_t n) {
+ char *p = (char *)llm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *llm_vsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return llm_strdup("");
+ char *p = (char *)llm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *llm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = llm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+static inline bool llm_streq(const char *a, const char *b) {
+ return a && b && strcmp(a, b) == 0;
+}
+
+/* First case-insensitive occurrence of `needle` in `hay` (portable
+ * strcasestr). */
+static inline const char *llm_strcasestr(const char *hay, const char *needle) {
+ size_t nn = strlen(needle);
+ if (nn == 0)
+ return hay;
+ for (const char *p = hay; *p; p++) {
+ bool ok = true;
+ for (size_t i = 0; i < nn; i++) {
+ if (tolower((unsigned char)p[i]) !=
+ tolower((unsigned char)needle[i])) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok)
+ return p;
+ }
+ return NULL;
+}
+
+static inline void llm_buf_init(llm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void llm_buf_free(llm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void llm_buf_reserve(llm_buf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *)llm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void llm_buf_putn(llm_buf *b, const char *s, size_t n) {
+ llm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void llm_buf_put(llm_buf *b, const char *s) {
+ if (s)
+ llm_buf_putn(b, s, strlen(s));
+}
+
+static inline void llm_buf_push(llm_buf *b, char c) {
+ llm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline char *llm_buf_steal(llm_buf *b) {
+ if (!b->data)
+ b->data = (char *)llm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* Small owned string list. */
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} llm_strlist;
+
+static inline void llm_sl_init(llm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void llm_sl_free(llm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ llm_sl_init(l);
+}
+
+static inline void llm_sl_push_copy(llm_strlist *l, const char *s) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items =
+ (char **)llm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = llm_strdup(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Request side */
+/* ------------------------------------------------------------------ */
+
+static inline char *llm_url(const llm_conn *c) {
+ const char *def;
+ switch (c->provider) {
+ case LLM_ANTHROPIC:
+ def = "https://api.anthropic.com";
+ break;
+ case LLM_OLLAMA:
+ def = "http://localhost:11434";
+ break;
+ case LLM_OPENAI:
+ default:
+ def = "https://api.openai.com/v1";
+ break;
+ }
+ char *base = llm_strdup(c->base_url ? c->base_url : def);
+ size_t n = strlen(base);
+ while (n && base[n - 1] == '/')
+ base[--n] = '\0';
+ llm_buf u;
+ llm_buf_init(&u);
+ llm_buf_put(&u, base);
+ free(base);
+ switch (c->provider) {
+ case LLM_ANTHROPIC:
+ llm_buf_put(&u, "/v1/messages");
+ break;
+ case LLM_OLLAMA:
+ llm_buf_put(&u, "/api/chat");
+ break;
+ case LLM_OPENAI:
+ default:
+ llm_buf_put(&u, "/chat/completions");
+ break;
+ }
+ char *out = llm_buf_steal(&u);
+ llm_buf_free(&u);
+ return out;
+}
+
+static inline void llm_auth(const llm_conn *c, char **header, char **value) {
+ if (header)
+ *header = NULL;
+ if (value)
+ *value = NULL;
+ if (!c->api_key)
+ return;
+ switch (c->provider) {
+ case LLM_OPENAI:
+ if (header)
+ *header = llm_strdup("Authorization");
+ if (value)
+ *value = llm_strfmt("Bearer %s", c->api_key);
+ break;
+ case LLM_ANTHROPIC:
+ if (header)
+ *header = llm_strdup("x-api-key");
+ if (value)
+ *value = llm_strdup(c->api_key);
+ break;
+ case LLM_OLLAMA:
+ break;
+ }
+}
+
+/* Shared setup: cached-or-throwaway curl handle, the JSON + auth header
+ * list, and the common POST options. Returns the handle (or NULL with
+ * *err set) and the header list in *out_h (the caller frees it with
+ * curl_slist_free_all). */
+static inline CURL *llm_post_setup(const llm_conn *c, const char *url,
+ const char *body,
+ struct curl_slist **out_h, char **err,
+ CURL **cached) {
+ CURL *h = cached ? *cached : NULL;
+ if (!h) {
+ h = curl_easy_init();
+ if (h && cached)
+ *cached = h;
+ }
+ if (!h) {
+ if (err)
+ *err = llm_strdup("could not initialize curl");
+ return NULL;
+ }
+ struct curl_slist *hl = NULL;
+ hl = curl_slist_append(hl, "Content-Type: application/json");
+ char *ah = NULL, *av = NULL;
+ llm_auth(c, &ah, &av);
+ if (ah && av) {
+ llm_buf hb;
+ llm_buf_init(&hb);
+ char *combo = llm_strfmt("%s: %s", ah, av);
+ llm_buf_put(&hb, combo);
+ free(combo);
+ hl = curl_slist_append(hl, hb.data);
+ llm_buf_free(&hb);
+ free(ah);
+ free(av);
+ }
+ curl_easy_setopt(h, CURLOPT_HTTPHEADER, hl);
+ curl_easy_setopt(h, CURLOPT_URL, url);
+ curl_easy_setopt(h, CURLOPT_POST, 1L);
+ curl_easy_setopt(h, CURLOPT_POSTFIELDS, body);
+ curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
+ curl_easy_setopt(h, CURLOPT_TIMEOUT, 600L);
+ curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L);
+ if (c->ca_bundle)
+ curl_easy_setopt(h, CURLOPT_CAINFO, c->ca_bundle);
+ *out_h = hl;
+ return h;
+}
+
+typedef struct {
+ llm_buf body;
+ llm_buf headers;
+ volatile bool *stop;
+} llm_post_ctx;
+
+static inline size_t llm_post_write(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = ud;
+ llm_buf_putn(&c->body, d, s * n);
+ return s * n;
+}
+
+static inline size_t llm_post_header(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = ud;
+ llm_buf_putn(&c->headers, d, s * n);
+ return s * n;
+}
+
+static inline int llm_post_xfer(void *clientp, curl_off_t t, curl_off_t dn,
+ curl_off_t ut, curl_off_t un) {
+ (void)t;
+ (void)dn;
+ (void)ut;
+ (void)un;
+ llm_post_ctx *c = clientp;
+ if (c->stop && *c->stop)
+ return 1; /* abort the transfer */
+ return 0;
+}
+
+static inline int llm_post(const llm_conn *c, const char *url,
+ const char *body, long *status_out, char **body_out,
+ char **headers_out, char **err, volatile bool *stop,
+ CURL **cached) {
+ if (body_out)
+ *body_out = NULL;
+ if (headers_out)
+ *headers_out = NULL;
+ if (err)
+ *err = NULL;
+
+ llm_post_ctx ctx;
+ llm_buf_init(&ctx.body);
+ llm_buf_init(&ctx.headers);
+ ctx.stop = stop;
+
+ struct curl_slist *h = NULL;
+ CURL *curl =
+ llm_post_setup(c, url, body, &h, err, cached);
+ if (!curl) {
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ return -1;
+ }
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_post_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx.body);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_post_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_post_xfer);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx);
+
+ CURLcode rc = curl_easy_perform(curl);
+ long code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ curl_slist_free_all(h);
+ char *body_str = llm_buf_steal(&ctx.body);
+ char *hdr_str = llm_buf_steal(&ctx.headers);
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ if (!cached || *cached != curl)
+ curl_easy_cleanup(curl); /* throwaway handle; the cached one outlives us */
+
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = llm_strdup(curl_easy_strerror(rc));
+ free(body_str);
+ free(hdr_str);
+ return -1;
+ }
+ if (status_out)
+ *status_out = code;
+ if (body_out)
+ *body_out = body_str;
+ else
+ free(body_str);
+ if (headers_out)
+ *headers_out = hdr_str;
+ else
+ free(hdr_str);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Wire messages -> provider JSON */
+/* ------------------------------------------------------------------ */
+
+/* Recover the tool-call ids in order (they live on the tool results). */
+static inline void llm_collect_result_ids(const llm_message *msgs,
+ size_t n, llm_strlist *out) {
+ llm_sl_init(out);
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role != LLM_ROLE_USER)
+ continue;
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TOOL_RESULT)
+ llm_sl_push_copy(out, p->call ? p->call : "");
+ }
+ }
+}
+
+static inline char *llm_id_or(const llm_strlist *ids, size_t i) {
+ if (i < ids->len && ids->items[i] && ids->items[i][0])
+ return llm_strdup(ids->items[i]);
+ return llm_strfmt("call-%d", (int)(i + 1));
+}
+
+static inline cJSON *llm_openai_messages(const llm_message *msgs,
+ size_t n) {
+ cJSON *arr = cJSON_CreateArray();
+ llm_strlist ids;
+ llm_collect_result_ids(msgs, n, &ids);
+ size_t call_idx = 0, result_idx = 0;
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role == LLM_ROLE_SYSTEM) {
+ const char *text = "";
+ if (m->part_count && m->parts[0].text)
+ text = m->parts[0].text;
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "system");
+ cJSON_AddStringToObject(o, "content", text);
+ cJSON_AddItemToArray(arr, o);
+ } else if (m->role == LLM_ROLE_USER) {
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "user");
+ cJSON_AddStringToObject(o, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(arr, o);
+ } else if (p->kind == LLM_TOOL_RESULT) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "tool");
+ char *id = llm_id_or(&ids, result_idx);
+ result_idx++;
+ cJSON_AddStringToObject(o, "tool_call_id", id);
+ free(id);
+ if (p->name)
+ cJSON_AddStringToObject(o, "name", p->name);
+ cJSON_AddStringToObject(o, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(arr, o);
+ }
+ }
+ } else { /* assistant */
+ llm_buf txt;
+ llm_buf_init(&txt);
+ bool first = true;
+ cJSON *tcs = NULL;
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ if (!first)
+ llm_buf_push(&txt, '\n');
+ if (p->text)
+ llm_buf_put(&txt, p->text);
+ first = false;
+ } else if (p->kind == LLM_TOOL_CALL) {
+ if (!tcs)
+ tcs = cJSON_CreateArray();
+ cJSON *tcobj = cJSON_CreateObject();
+ char *id = llm_id_or(&ids, call_idx);
+ cJSON_AddStringToObject(tcobj, "id", id);
+ free(id);
+ cJSON_AddStringToObject(tcobj, "type", "function");
+ cJSON *fn = cJSON_AddObjectToObject(tcobj, "function");
+ cJSON_AddStringToObject(fn, "name",
+ p->name ? p->name : "");
+ cJSON_AddStringToObject(fn, "arguments",
+ p->args ? p->args : "{}");
+ cJSON_AddItemToArray(tcs, tcobj);
+ call_idx++;
+ }
+ }
+ char *t = llm_buf_steal(&txt);
+ llm_buf_free(&txt);
+ if (t[0] || tcs) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "assistant");
+ if (t[0])
+ cJSON_AddStringToObject(o, "content", t);
+ else
+ cJSON_AddItemToObject(o, "content", cJSON_CreateNull());
+ if (tcs)
+ cJSON_AddItemToObject(o, "tool_calls", tcs);
+ cJSON_AddItemToArray(arr, o);
+ }
+ free(t);
+ }
+ }
+ llm_sl_free(&ids);
+ return arr;
+}
+
+static inline cJSON *llm_anthropic_messages(const llm_message *msgs,
+ size_t n) {
+ cJSON *arr = cJSON_CreateArray();
+ llm_strlist ids;
+ llm_collect_result_ids(msgs, n, &ids);
+ size_t call_idx = 0, result_idx = 0;
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role == LLM_ROLE_SYSTEM)
+ continue; /* folded into the top-level `system` */
+ cJSON *o = cJSON_CreateObject();
+ cJSON *content = cJSON_CreateArray();
+ if (m->role == LLM_ROLE_USER) {
+ cJSON_AddStringToObject(o, "role", "user");
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "text");
+ cJSON_AddStringToObject(b, "text",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ } else if (p->kind == LLM_TOOL_RESULT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "tool_result");
+ char *id = llm_id_or(&ids, result_idx);
+ result_idx++;
+ cJSON_AddStringToObject(b, "tool_use_id", id);
+ free(id);
+ cJSON_AddStringToObject(b, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ }
+ }
+ } else { /* assistant */
+ cJSON_AddStringToObject(o, "role", "assistant");
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "text");
+ cJSON_AddStringToObject(b, "text",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ } else if (p->kind == LLM_TOOL_CALL) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "tool_use");
+ char *id = llm_id_or(&ids, call_idx);
+ cJSON_AddStringToObject(b, "id", id);
+ free(id);
+ cJSON_AddStringToObject(b, "name",
+ p->name ? p->name : "");
+ cJSON *inp =
+ p->args ? cJSON_Parse(p->args) : NULL;
+ if (!inp)
+ inp = cJSON_CreateObject();
+ cJSON_AddItemToObject(b, "input", inp);
+ cJSON_AddItemToArray(content, b);
+ call_idx++;
+ }
+ }
+ }
+ cJSON_AddItemToObject(o, "content", content);
+ cJSON_AddItemToArray(arr, o);
+ }
+ llm_sl_free(&ids);
+ return arr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Response parsing */
+/* ------------------------------------------------------------------ */
+
+static inline void llm_completion_free(llm_completion *c) {
+ free(c->text);
+ free(c->reasoning);
+ for (size_t i = 0; i < c->tc_len; i++) {
+ free(c->tc_id[i]);
+ free(c->tc_name[i]);
+ free(c->tc_args[i]);
+ }
+ free(c->tc_id);
+ free(c->tc_name);
+ free(c->tc_args);
+ c->text = NULL;
+ c->reasoning = NULL;
+ c->tc_id = c->tc_name = c->tc_args = NULL;
+ c->tc_len = 0;
+ c->input_tokens = 0;
+}
+
+static inline void llm_add_tool_call(llm_completion *c, char *id, char *name,
+ char *args) {
+ c->tc_id = llm_xrealloc(c->tc_id, (c->tc_len + 1) * sizeof *c->tc_id);
+ c->tc_name = llm_xrealloc(c->tc_name, (c->tc_len + 1) * sizeof *c->tc_name);
+ c->tc_args = llm_xrealloc(c->tc_args, (c->tc_len + 1) * sizeof *c->tc_args);
+ c->tc_id[c->tc_len] = id;
+ c->tc_name[c->tc_len] = name;
+ c->tc_args[c->tc_len] = args;
+ c->tc_len++;
+}
+
+static inline void llm_parse_openai(const cJSON *resp, llm_completion *c) {
+ const cJSON *choices = cJSON_GetObjectItem(resp, "choices");
+ if (cJSON_IsArray(choices) && choices->child) {
+ const cJSON *choice = choices->child;
+ const cJSON *msg = cJSON_GetObjectItem(choice, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring)
+ c->text = llm_strdup(ct->valuestring);
+ /* Reasoning models (o1/o3 and compatibles) return the chain of
+ * thought in `reasoning_content`; some servers use `reasoning`. */
+ const cJSON *rc = cJSON_GetObjectItem(msg, "reasoning_content");
+ if (!cJSON_IsString(rc) || !rc->valuestring)
+ rc = cJSON_GetObjectItem(msg, "reasoning");
+ if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0])
+ c->reasoning = llm_strdup(rc->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ char *id = NULL, *name = NULL, *args = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ args = llm_strdup(ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ args = s ? s : llm_strdup("{}");
+ }
+ }
+ llm_add_tool_call(
+ c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)),
+ name ? name : llm_strdup(""),
+ args ? args : llm_strdup("{}"));
+ }
+ }
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(resp, "usage");
+ if (usage) {
+ const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens");
+ if (cJSON_IsNumber(pt))
+ c->input_tokens = (unsigned long)pt->valuedouble;
+ }
+}
+
+static inline void llm_parse_anthropic(const cJSON *resp, llm_completion *c) {
+ const cJSON *content = cJSON_GetObjectItem(resp, "content");
+ if (cJSON_IsArray(content)) {
+ const cJSON *b = NULL;
+ cJSON_ArrayForEach(b, content) {
+ const cJSON *type = cJSON_GetObjectItem(b, "type");
+ const char *t = (cJSON_IsString(type) && type->valuestring)
+ ? type->valuestring
+ : "";
+ if (llm_streq(t, "text")) {
+ const cJSON *txt = cJSON_GetObjectItem(b, "text");
+ if (cJSON_IsString(txt) && txt->valuestring) {
+ free(c->text);
+ c->text = llm_strdup(txt->valuestring);
+ }
+ } else if (llm_streq(t, "thinking")) {
+ const cJSON *th = cJSON_GetObjectItem(b, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) {
+ free(c->reasoning);
+ c->reasoning = llm_strdup(th->valuestring);
+ }
+ } else if (llm_streq(t, "tool_use")) {
+ char *id = NULL, *name = NULL, *args = NULL;
+ const cJSON *tid = cJSON_GetObjectItem(b, "id");
+ if (cJSON_IsString(tid) && tid->valuestring)
+ id = llm_strdup(tid->valuestring);
+ const cJSON *tn = cJSON_GetObjectItem(b, "name");
+ if (cJSON_IsString(tn) && tn->valuestring)
+ name = llm_strdup(tn->valuestring);
+ const cJSON *tin = cJSON_GetObjectItem(b, "input");
+ if (cJSON_IsObject(tin)) {
+ char *s = cJSON_PrintUnformatted(tin);
+ args = s ? s : llm_strdup("{}");
+ }
+ llm_add_tool_call(
+ c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)),
+ name ? name : llm_strdup(""),
+ args ? args : llm_strdup("{}"));
+ }
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(resp, "usage");
+ if (usage) {
+ const cJSON *it = cJSON_GetObjectItem(usage, "input_tokens");
+ if (cJSON_IsNumber(it))
+ c->input_tokens = (unsigned long)it->valuedouble;
+ }
+}
+
+static inline void llm_parse_ollama(const cJSON *resp, llm_completion *c) {
+ const cJSON *msg = cJSON_GetObjectItem(resp, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring)
+ c->text = llm_strdup(ct->valuestring);
+ /* Thinking models (qwen3, deepseek-r1, …) return `message.thinking`. */
+ const cJSON *th = cJSON_GetObjectItem(msg, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0])
+ c->reasoning = llm_strdup(th->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ const cJSON *id = cJSON_GetObjectItem(tc, "id");
+ char *cid = (cJSON_IsString(id) && id->valuestring)
+ ? llm_strdup(id->valuestring)
+ : llm_strfmt("call-%d", (int)(c->tc_len + 1));
+ char *name = llm_strdup(""), *args = llm_strdup("{}");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring) {
+ free(name);
+ name = llm_strdup(cn->valuestring);
+ }
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring) {
+ free(args);
+ args = llm_strdup(ca->valuestring);
+ } else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ free(args);
+ args = s;
+ }
+ }
+ }
+ llm_add_tool_call(c, cid, name, args);
+ }
+ }
+ }
+ const cJSON *pe = cJSON_GetObjectItem(resp, "prompt_eval_count");
+ if (cJSON_IsNumber(pe))
+ c->input_tokens = (unsigned long)pe->valuedouble;
+}
+
+static inline int llm_parse_body(const llm_conn *c, const char *body,
+ llm_completion *out) {
+ if (!body || !body[0])
+ return -1;
+ cJSON *resp = cJSON_Parse(body);
+ if (!resp)
+ return -1;
+ if (c->provider == LLM_ANTHROPIC)
+ llm_parse_anthropic(resp, out);
+ else if (c->provider == LLM_OLLAMA)
+ llm_parse_ollama(resp, out);
+ else
+ llm_parse_openai(resp, out);
+ cJSON_Delete(resp);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Error body + Retry-After */
+/* ------------------------------------------------------------------ */
+
+static inline char *llm_parse_error(const char *body, long status) {
+ char *msg = NULL;
+ if (body && body[0]) {
+ cJSON *j = cJSON_Parse(body);
+ if (j) {
+ const cJSON *e = cJSON_GetObjectItem(j, "error");
+ const cJSON *em = cJSON_IsObject(e)
+ ? cJSON_GetObjectItem(e, "message")
+ : (cJSON_IsString(e) ? e : NULL);
+ if (cJSON_IsString(em) && em->valuestring)
+ msg = llm_strdup(em->valuestring);
+ if (!msg)
+ msg = llm_strdup("");
+ cJSON_Delete(j);
+ } else {
+ size_t cap = strlen(body);
+ if (cap > 500)
+ cap = 500;
+ msg = llm_strndup(body, cap);
+ }
+ }
+ if (!msg || !msg[0])
+ msg = llm_strfmt("HTTP %ld", status);
+ return msg;
+}
+
+static inline unsigned long llm_retry_after(const char *val) {
+ if (!val)
+ return 0;
+ char *end = NULL;
+ long v = strtol(val, &end, 10);
+ if (end != val && v >= 0 && v <= 300)
+ return (unsigned long)v;
+ return 0;
+}
+
+static inline unsigned long llm_header_retry_after(const char *headers) {
+ if (!headers)
+ return 0;
+ const char *p = headers;
+ while ((p = llm_strcasestr(p, "retry-after")) != NULL) {
+ p += strlen("retry-after");
+ while (*p == ':' || *p == ' ' || *p == '\t')
+ p++;
+ /* find the value up to CRLF */
+ char *val = llm_strdup(p);
+ size_t n = strlen(val);
+ while (n && (val[n - 1] == '\r' || val[n - 1] == '\n'))
+ val[--n] = '\0';
+ unsigned long r = llm_retry_after(val);
+ free(val);
+ if (r)
+ return r;
+ p += 1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* SSE streaming */
+/* ------------------------------------------------------------------ */
+
+static inline void llm_sse_acc_init(llm_sse_acc *a, llm_provider prov) {
+ (void)prov;
+ memset(a, 0, sizeof *a);
+ llm_buf_init(&a->text);
+ llm_buf_init(&a->reasoning);
+}
+
+static inline void llm_sse_acc_free(llm_sse_acc *a) {
+ llm_buf_free(&a->text);
+ llm_buf_free(&a->reasoning);
+ for (size_t i = 0; i < a->tc_len; i++) {
+ free(a->tc_id[i]);
+ free(a->tc_name[i]);
+ llm_buf_free(&a->tc_args[i]);
+ }
+ free(a->tc_id);
+ free(a->tc_name);
+ free(a->tc_args);
+ free(a->anth_event);
+ memset(a, 0, sizeof *a);
+}
+
+static inline void llm_acc_push_tool(llm_sse_acc *a, char *id, char *name) {
+ a->tc_id = llm_xrealloc(a->tc_id, (a->tc_len + 1) * sizeof *a->tc_id);
+ a->tc_name =
+ llm_xrealloc(a->tc_name, (a->tc_len + 1) * sizeof *a->tc_name);
+ a->tc_args =
+ llm_xrealloc(a->tc_args, (a->tc_len + 1) * sizeof *a->tc_args);
+ a->tc_id[a->tc_len] = id;
+ a->tc_name[a->tc_len] = name;
+ llm_buf_init(&a->tc_args[a->tc_len]);
+ a->tc_len++;
+}
+
+/* The args buffer of the tool call at `idx` (creating the slot when the
+ * stream has not announced it yet). Fresh slots adopt `id`/`name`. */
+static inline llm_buf *llm_acc_tool_args(llm_sse_acc *a, size_t idx, char *id,
+ char *name) {
+ while (a->tc_len <= idx)
+ llm_acc_push_tool(a, NULL, NULL);
+ if (id) {
+ free(a->tc_id[idx]);
+ a->tc_id[idx] = id;
+ }
+ if (name) {
+ if (a->tc_name[idx] && a->tc_name[idx][0])
+ free(name);
+ else {
+ free(a->tc_name[idx]);
+ a->tc_name[idx] = name;
+ }
+ }
+ return &a->tc_args[idx];
+}
+
+static inline void llm_sse_parse_line(llm_provider prov, const char *line,
+ llm_sse_acc *a) {
+ const char *p = line;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (!*p)
+ return;
+
+ if (prov == LLM_ANTHROPIC) {
+ if (strncmp(p, "event:", 6) == 0) {
+ const char *v = p + 6;
+ while (*v == ' ')
+ v++;
+ char *ev = llm_strdup(v);
+ size_t n = strlen(ev);
+ while (n && (ev[n - 1] == '\r' || ev[n - 1] == '\n'))
+ ev[--n] = '\0';
+ free(a->anth_event);
+ a->anth_event = ev;
+ return;
+ }
+ if (strncmp(p, "data:", 5) != 0)
+ return;
+ const char *v = p + 5;
+ while (*v == ' ')
+ v++;
+ const char *ev = a->anth_event ? a->anth_event : "";
+ /* message_stop arrives as `data: [DONE]` (not JSON). */
+ if (llm_streq(ev, "message_stop")) {
+ a->done = true;
+ return;
+ }
+ cJSON *j = cJSON_Parse(v);
+ if (!j)
+ return;
+ if (llm_streq(ev, "message_start")) {
+ const cJSON *m = cJSON_GetObjectItem(j, "message");
+ const cJSON *u = m ? cJSON_GetObjectItem(m, "usage") : NULL;
+ if (u) {
+ const cJSON *it = cJSON_GetObjectItem(u, "input_tokens");
+ if (cJSON_IsNumber(it))
+ a->input_tokens = (unsigned long)it->valuedouble;
+ }
+ } else if (llm_streq(ev, "content_block_start")) {
+ const cJSON *cb = cJSON_GetObjectItem(j, "content_block");
+ const cJSON *t = cb ? cJSON_GetObjectItem(cb, "type") : NULL;
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (llm_streq(t->valuestring, "thinking")) {
+ a->anth_mode = 1;
+ } else if (llm_streq(t->valuestring, "tool_use")) {
+ char *id = NULL, *name = NULL;
+ const cJSON *tid = cJSON_GetObjectItem(cb, "id");
+ if (cJSON_IsString(tid) && tid->valuestring)
+ id = llm_strdup(tid->valuestring);
+ const cJSON *tn = cJSON_GetObjectItem(cb, "name");
+ if (cJSON_IsString(tn) && tn->valuestring)
+ name = llm_strdup(tn->valuestring);
+ llm_acc_push_tool(a, id, name);
+ a->anth_mode = 2;
+ a->anth_tc = a->tc_len - 1;
+ } else {
+ a->anth_mode = 0;
+ }
+ }
+ } else if (llm_streq(ev, "content_block_delta")) {
+ const cJSON *d = cJSON_GetObjectItem(j, "delta");
+ const cJSON *t = d ? cJSON_GetObjectItem(d, "type") : NULL;
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (llm_streq(t->valuestring, "text_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "text");
+ if (cJSON_IsString(s) && s->valuestring)
+ llm_buf_put(&a->text, s->valuestring);
+ } else if (llm_streq(t->valuestring, "thinking_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "thinking");
+ if (cJSON_IsString(s) && s->valuestring)
+ llm_buf_put(&a->reasoning, s->valuestring);
+ } else if (llm_streq(t->valuestring, "input_json_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "partial_json");
+ if (cJSON_IsString(s) && s->valuestring &&
+ a->anth_mode == 2)
+ llm_buf_put(&a->tc_args[a->anth_tc], s->valuestring);
+ }
+ }
+ }
+ cJSON_Delete(j);
+ return;
+ }
+
+ if (prov == LLM_OLLAMA) {
+ cJSON *j = cJSON_Parse(p);
+ if (!j)
+ return;
+ const cJSON *msg = cJSON_GetObjectItem(j, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0])
+ llm_buf_put(&a->text, ct->valuestring);
+ const cJSON *th = cJSON_GetObjectItem(msg, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0])
+ llm_buf_put(&a->reasoning, th->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ char *id = NULL, *name = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ }
+ llm_buf *ab = llm_acc_tool_args(a, a->tc_len, id, name);
+ if (fn) {
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ llm_buf_put(ab, ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ llm_buf_put(ab, s);
+ free(s);
+ }
+ }
+ }
+ }
+ }
+ }
+ const cJSON *pe = cJSON_GetObjectItem(j, "prompt_eval_count");
+ if (cJSON_IsNumber(pe))
+ a->input_tokens = (unsigned long)pe->valuedouble;
+ const cJSON *dn = cJSON_GetObjectItem(j, "done");
+ if (cJSON_IsBool(dn) && dn->valueint)
+ a->done = true;
+ cJSON_Delete(j);
+ return;
+ }
+
+ /* OpenAI-compatible SSE. */
+ if (strncmp(p, "data:", 5) != 0)
+ return;
+ const char *v = p + 5;
+ while (*v == ' ')
+ v++;
+ if (llm_streq(v, "[DONE]")) {
+ a->done = true;
+ return;
+ }
+ cJSON *j = cJSON_Parse(v);
+ if (!j)
+ return;
+ const cJSON *choices = cJSON_GetObjectItem(j, "choices");
+ if (cJSON_IsArray(choices) && choices->child) {
+ const cJSON *choice = choices->child;
+ const cJSON *delta = cJSON_GetObjectItem(choice, "delta");
+ if (delta) {
+ const cJSON *ct = cJSON_GetObjectItem(delta, "content");
+ if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0])
+ llm_buf_put(&a->text, ct->valuestring);
+ const cJSON *rc = cJSON_GetObjectItem(delta, "reasoning_content");
+ if (!cJSON_IsString(rc) || !rc->valuestring)
+ rc = cJSON_GetObjectItem(delta, "reasoning");
+ if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0])
+ llm_buf_put(&a->reasoning, rc->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(delta, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ size_t idx = 0;
+ const cJSON *ci = cJSON_GetObjectItem(tc, "index");
+ if (cJSON_IsNumber(ci))
+ idx = (size_t)ci->valuedouble;
+ char *id = NULL, *name = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ }
+ llm_buf *ab = llm_acc_tool_args(a, idx, id, name);
+ if (fn) {
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ llm_buf_put(ab, ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ llm_buf_put(ab, s);
+ free(s);
+ }
+ }
+ }
+ }
+ }
+ const cJSON *fr = cJSON_GetObjectItem(choice, "finish_reason");
+ if (cJSON_IsString(fr) && fr->valuestring && fr->valuestring[0])
+ a->done = true;
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(j, "usage");
+ if (usage) {
+ const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens");
+ if (cJSON_IsNumber(pt))
+ a->input_tokens = (unsigned long)pt->valuedouble;
+ }
+ cJSON_Delete(j);
+}
+
+static inline bool llm_sse_acc_had_content(const llm_sse_acc *a) {
+ return a->text.len > 0 || a->reasoning.len > 0 || a->tc_len > 0 || a->done;
+}
+
+/* Materialize the accumulated stream into `c` (ownership transfers). */
+static inline void llm_sse_acc_into(llm_sse_acc *a, llm_completion *c) {
+ /* Ownership transfers to `c` when non-empty; only free the buffer when
+ * `c` did not take it (empty). Freeing unconditionally would dangle the
+ * pointer `c` now owns -> double-free in llm_completion_free. */
+ char *t = llm_buf_steal(&a->text);
+ if (t && t[0])
+ c->text = t;
+ else
+ free(t);
+ char *r = llm_buf_steal(&a->reasoning);
+ if (r && r[0])
+ c->reasoning = r;
+ else
+ free(r);
+ for (size_t i = 0; i < a->tc_len; i++) {
+ char *args = llm_buf_steal(&a->tc_args[i]);
+ char *id = a->tc_id[i] ? a->tc_id[i]
+ : llm_strfmt("call-%d", (int)(i + 1));
+ char *name = a->tc_name[i] ? a->tc_name[i] : llm_strdup("");
+ if (!args || !args[0]) {
+ free(args);
+ args = llm_strdup("{}");
+ }
+ llm_add_tool_call(c, id, name, args);
+ a->tc_id[i] = a->tc_name[i] = NULL;
+ }
+ c->input_tokens = a->input_tokens;
+ a->input_tokens = 0;
+}
+
+/* Write-callback state for a streaming POST. */
+typedef struct {
+ llm_buf body;
+ llm_buf headers;
+ llm_buf linebuf; /* bytes not yet terminated by a newline */
+ llm_sse_acc *acc;
+ llm_provider prov;
+ llm_emit_cb emit;
+ void *user;
+ volatile bool *stop;
+ bool stopped; /* set when the transfer should abort */
+ size_t emitted_text;
+ size_t emitted_reasoning;
+} llm_stream_ctx;
+
+static inline size_t llm_stream_write(char *d, size_t s, size_t n, void *ud) {
+ llm_stream_ctx *c = ud;
+ llm_buf_putn(&c->body, d, s * n);
+ llm_buf_putn(&c->linebuf, d, s * n);
+ for (;;) {
+ char *nl = (char *)memchr(c->linebuf.data, '\n', c->linebuf.len);
+ if (!nl)
+ break;
+ size_t lpos = (size_t)(nl - c->linebuf.data);
+ if (lpos > 0 && c->linebuf.data[lpos - 1] == '\r')
+ lpos--;
+ char *line = llm_strndup(c->linebuf.data, lpos);
+ size_t rest = c->linebuf.len - lpos - 1;
+ memmove(c->linebuf.data, c->linebuf.data + lpos + 1, rest);
+ c->linebuf.len = rest;
+ llm_sse_parse_line(c->prov, line, c->acc);
+ free(line);
+
+ if (c->acc->text.len > c->emitted_text) {
+ char *delta = llm_strndup(c->acc->text.data + c->emitted_text,
+ c->acc->text.len - c->emitted_text);
+ c->emitted_text = c->acc->text.len;
+ if (c->emit &&
+ c->emit(c->prov, LLM_EMIT_TEXT, delta, c->user) != 0)
+ c->stopped = true;
+ free(delta);
+ }
+ if (!c->stopped && c->acc->reasoning.len > c->emitted_reasoning) {
+ char *delta = llm_strndup(c->acc->reasoning.data +
+ c->emitted_reasoning,
+ c->acc->reasoning.len -
+ c->emitted_reasoning);
+ c->emitted_reasoning = c->acc->reasoning.len;
+ if (c->emit &&
+ c->emit(c->prov, LLM_EMIT_REASONING, delta, c->user) != 0)
+ c->stopped = true;
+ free(delta);
+ }
+ if (c->stopped || (c->stop && *c->stop)) {
+ c->stopped = true;
+ break;
+ }
+ }
+ if (c->stopped)
+ return 0; /* short write: abort the transfer */
+ return s * n;
+}
+
+static inline size_t llm_stream_header(char *d, size_t s, size_t n, void *ud) {
+ llm_stream_ctx *c = ud;
+ llm_buf_putn(&c->headers, d, s * n);
+ return s * n;
+}
+
+static inline int llm_stream_xfer(void *clientp, curl_off_t t, curl_off_t dn,
+ curl_off_t ut, curl_off_t un) {
+ (void)t;
+ (void)dn;
+ (void)ut;
+ (void)un;
+ llm_stream_ctx *c = clientp;
+ if (c->stop && *c->stop)
+ return 1;
+ return 0;
+}
+
+static inline int llm_post_stream(const llm_conn *c, const char *url,
+ const char *body, long *status_out,
+ char **body_out, char **headers_out,
+ llm_sse_acc *acc, llm_emit_cb emit, void *user,
+ volatile bool *stop, bool *stopped, char **err,
+ CURL **cached) {
+ if (body_out)
+ *body_out = NULL;
+ if (headers_out)
+ *headers_out = NULL;
+ if (stopped)
+ *stopped = false;
+ if (err)
+ *err = NULL;
+ if (status_out)
+ *status_out = 0;
+
+ llm_stream_ctx ctx;
+ memset(&ctx, 0, sizeof ctx);
+ llm_buf_init(&ctx.body);
+ llm_buf_init(&ctx.headers);
+ llm_buf_init(&ctx.linebuf);
+ ctx.acc = acc;
+ ctx.prov = c->provider;
+ ctx.emit = emit;
+ ctx.user = user;
+ ctx.stop = stop;
+
+ struct curl_slist *h = NULL;
+ CURL *curl = llm_post_setup(c, url, body, &h, err, cached);
+ if (!curl) {
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ llm_buf_free(&ctx.linebuf);
+ return -1;
+ }
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_stream_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_stream_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_stream_xfer);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx);
+
+ CURLcode rc = curl_easy_perform(curl);
+ long code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ curl_slist_free_all(h);
+ char *body_str = llm_buf_steal(&ctx.body);
+ char *hdr_str = llm_buf_steal(&ctx.headers);
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ llm_buf_free(&ctx.linebuf);
+ if (!cached || *cached != curl)
+ curl_easy_cleanup(curl);
+
+ if (ctx.stopped) {
+ if (stopped)
+ *stopped = true;
+ free(body_str);
+ free(hdr_str);
+ return 0;
+ }
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = llm_strdup(curl_easy_strerror(rc));
+ free(body_str);
+ free(hdr_str);
+ return -1;
+ }
+ if (status_out)
+ *status_out = code;
+ if (body_out)
+ *body_out = body_str;
+ else
+ free(body_str);
+ if (headers_out)
+ *headers_out = hdr_str;
+ else
+ free(hdr_str);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Request assembly */
+/* ------------------------------------------------------------------ */
+
+/* JSON-encode an arbitrary string (NULL-safe). */
+static inline char *llm_json_str(const char *s) {
+ cJSON *j = cJSON_CreateString(s ? s : "");
+ char *r = cJSON_PrintUnformatted(j);
+ cJSON_Delete(j);
+ return r ? r : llm_strdup("\"\"");
+}
+
+/* OpenAI-compatible body (OpenAI + Ollama). `top_level_system` routes the
+ * preamble to a top-level `system` field (Ollama) instead of a leading
+ * system message (OpenAI). */
+static inline void llm_emit_openai_body(llm_buf *req, const llm_conn *c,
+ const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json,
+ bool top_level_system, bool stream) {
+ llm_buf_put(req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(req, model);
+ free(model);
+ llm_buf_put(req, ",\"stream\":");
+ llm_buf_put(req, stream ? "true" : "false");
+ llm_buf_push(req, ',');
+ if (top_level_system && preamble && preamble[0]) {
+ char *ps = llm_json_str(preamble);
+ llm_buf_put(req, "\"system\":");
+ llm_buf_put(req, ps);
+ free(ps);
+ llm_buf_push(req, ',');
+ }
+ llm_buf_put(req, "\"messages\":");
+ cJSON *om = llm_openai_messages(msgs, n);
+ if (!top_level_system && preamble && preamble[0]) {
+ /* OpenAI-compatible: the preamble becomes a leading system message. */
+ cJSON *sys = cJSON_CreateObject();
+ cJSON_AddStringToObject(sys, "role", "system");
+ cJSON_AddStringToObject(sys, "content", preamble);
+ cJSON_InsertItemInArray(om, 0, sys);
+ }
+ char *oms = cJSON_PrintUnformatted(om);
+ llm_buf_put(req, oms ? oms : "[]");
+ free(oms);
+ cJSON_Delete(om);
+ if (tools_json) {
+ llm_buf_push(req, ',');
+ llm_buf_put(req, tools_json);
+ }
+ llm_buf_push(req, '}');
+}
+
+static inline char *llm_build_request(const llm_conn *c, const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json, bool stream) {
+ llm_buf req;
+ llm_buf_init(&req);
+ if (c->provider == LLM_ANTHROPIC) {
+ llm_buf_put(&req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(&req, model);
+ free(model);
+ llm_buf_put(&req, ",\"max_tokens\":8192,\"stream\":");
+ llm_buf_put(&req, stream ? "true" : "false");
+ llm_buf_push(&req, ',');
+ if (preamble && preamble[0]) {
+ char *ps = llm_json_str(preamble);
+ llm_buf_put(&req, "\"system\":");
+ llm_buf_put(&req, ps);
+ free(ps);
+ llm_buf_push(&req, ',');
+ }
+ llm_buf_put(&req, "\"messages\":");
+ cJSON *m = llm_anthropic_messages(msgs, n);
+ char *ms = cJSON_PrintUnformatted(m);
+ llm_buf_put(&req, ms ? ms : "[]");
+ free(ms);
+ cJSON_Delete(m);
+ if (tools_json) {
+ llm_buf_push(&req, ',');
+ llm_buf_put(&req, tools_json);
+ }
+ llm_buf_push(&req, '}');
+ } else if (c->provider == LLM_OLLAMA) {
+ /* Ollama takes the system prompt as a top-level `system` field. */
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, true,
+ stream);
+ } else {
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, false,
+ stream);
+ }
+ char *out = llm_buf_steal(&req);
+ llm_buf_free(&req);
+ return out;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* LLM_H */
diff --git a/tests/test_ann.c b/tests/test_ann.c
new file mode 100644
index 0000000..5192e36
--- /dev/null
+++ b/tests/test_ann.c
@@ -0,0 +1,284 @@
+// Tests for ann.h - neural network init, forward/backward pass, activations
+#include <stdio.h>
+#define ANN_IMPLEMENTATION
+#include "../include/ann.h"
+
+#include <math.h>
+#include <time.h>
+
+#define EPS 1e-4
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_ann.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_FLOAT_EQ(a, b, msg) \
+ do { \
+ if (fabs((a) - (b)) > EPS) { \
+ fprintf(stderr, "FAIL: %s (got %f, expected %f) (test_ann.c:%d)\n", \
+ msg, (double)(a), (double)(b), __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_init() {
+ ann_t ann = {0};
+ u64 layers[] = {2, 3, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+
+ ASSERT(ann.layer_n == 3, "layer count");
+ ASSERT(ann.buffer != NULL, "buffer allocated");
+ ASSERT(ann.layer_neuron_n != NULL, "layer_neuron_n set");
+ ASSERT(ann.layer_neuron_n[0] == 2, "input layer size");
+ ASSERT(ann.layer_neuron_n[1] == 3, "hidden layer size");
+ ASSERT(ann.layer_neuron_n[2] == 1, "output layer size");
+ ASSERT(ann.neuron != NULL, "neuron pointer set");
+ ASSERT(ann.weight != NULL, "weight pointer set");
+
+ ann_free(&ann);
+}
+
+void test_free() {
+ ann_t ann = {0};
+ u64 layers[] = {2, 4, 2};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+ ann_free(&ann);
+ ASSERT(ann.buffer == NULL, "buffer freed");
+
+ ann_free(&ann);
+}
+
+void test_copy() {
+ ann_t src = {0};
+ u64 layers[] = {2, 3, 1};
+ ASSERT(ann_init(&src, 3, layers), "ann_init succeeds");
+ ann_random(&src);
+
+ ann_t dst = {0};
+ ann_copy(&dst, &src);
+
+ ASSERT(dst.layer_n == src.layer_n, "copy layer_n");
+ ASSERT(dst.neuron_n == src.neuron_n, "copy neuron_n");
+ ASSERT(dst.weight_n == src.weight_n, "copy weight_n");
+ ASSERT(dst.buffer != src.buffer, "copy has separate buffer");
+
+ ann_free(&src);
+ ann_free(&dst);
+}
+
+void test_random() {
+ srand(42);
+
+ ann_t ann = {0};
+ u64 layers[] = {2, 2, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+ ann_random(&ann);
+
+ int has_nonzero = 0;
+ for (u64 i = 0; i < ann.weight_n; i++) {
+ if (ann.weight[i] != 0) {
+ has_nonzero = 1;
+ break;
+ }
+ }
+ ASSERT(has_nonzero, "random sets non-zero weights");
+
+ ann_free(&ann);
+}
+
+void test_forward() {
+ ann_t ann = {0};
+ u64 layers[] = {2, 2, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+
+ f64 *w = ann.weight;
+ for (u64 i = 0; i < ann.weight_n; i++) {
+ w[i] = 0;
+ }
+
+ f64 input[2] = {1, 1};
+ f64 output[1] = {0};
+ ann_propagation_forward(&ann, input, output);
+
+ ASSERT(output[0] >= 0 && output[0] <= 1, "forward output in sigmoid range");
+
+ ann_free(&ann);
+}
+
+void test_backward() {
+ ann_t ann = {0};
+ u64 layers[] = {2, 2, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+ ann_random(&ann);
+
+ f64 input[2] = {0.5, 0.3};
+ f64 output[1] = {0};
+ f64 target[1] = {1};
+
+ f64 weight_before = ann.weight[0];
+
+ ann_propagation_forward(&ann, input, output);
+
+ f64 output_before = output[0];
+
+ ann_propagation_backward(&ann, input, output, target, 0.1);
+
+ f64 weight_after = ann.weight[0];
+ ASSERT(weight_before != weight_after, "backward changes weights");
+
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(output[0] != output_before, "output changed after weight update");
+
+ ann_free(&ann);
+}
+
+void test_error() {
+ f64 err = ann_error_total((f64[]){0.9}, (f64[]){1.0}, 1);
+ ASSERT(err > 0, "error is positive");
+
+ f64 err_zero = ann_error_total((f64[]){1.0}, (f64[]){1.0}, 1);
+ ASSERT_FLOAT_EQ(err_zero, 0, "error zero when output == target");
+
+ f64 err1 = ann_error(0.5, 0.5);
+ ASSERT_FLOAT_EQ(err1, 0, "single error zero");
+
+ f64 err2 = ann_error(1.0, 0.0);
+ ASSERT_FLOAT_EQ(err2, 0.5, "single error 0.5");
+}
+
+void test_activations() {
+ ann_t ann = {0};
+ u64 layers[] = {2, 2, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+
+ ann_set_activation(&ann, SIGMOID, SIGMOID);
+ f64 input[2] = {0.5, 0.5};
+ f64 output[1] = {0};
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(output[0] >= 0 && output[0] <= 1, "sigmoid output range");
+
+ ann_set_activation(&ann, RELU, RELU);
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(output[0] >= 0, "relu output non-negative");
+
+ ann_set_activation(&ann, TANH, TANH);
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(output[0] >= -1 && output[0] <= 1, "tanh output range");
+
+ ann_set_activation(&ann, IDENTITY, IDENTITY);
+ ann_propagation_forward(&ann, input, output);
+
+ ann_set_activation(&ann, BINARY, BINARY);
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(output[0] == 0 || output[0] == 1, "binary output is 0 or 1");
+
+ ann_set_activation(&ann, ELU, ELU);
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(isfinite(output[0]), "elu output is finite");
+
+ ann_set_activation(&ann, LRELU, LRELU);
+ ann_propagation_forward(&ann, input, output);
+ ASSERT(isfinite(output[0]), "lrelu output is finite");
+
+ ann_free(&ann);
+}
+
+void test_train_numeric() {
+ srand(42);
+
+ ann_t ann = {0};
+ u64 layers[] = {2, 4, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+ ann_random(&ann);
+
+ f64 input[2] = {0, 1};
+ f64 target[1] = {1};
+ f64 output_before[1] = {0};
+ ann_propagation_forward(&ann, input, output_before);
+
+ ann_train_numeric(&ann, input, target, 1, 0.01);
+
+ f64 output_after[1] = {0};
+ ann_propagation_forward(&ann, input, output_after);
+ ASSERT(output_before[0] != output_after[0], "numeric training changes output");
+ ASSERT(isfinite(output_after[0]), "output is finite after training");
+
+ ann_free(&ann);
+}
+
+void test_xor_training() {
+ srand(42);
+
+ ann_t ann = {0};
+ u64 layers[] = {2, 4, 1};
+ ASSERT(ann_init(&ann, 3, layers), "ann_init succeeds");
+ ann_set_activation(&ann, SIGMOID, SIGMOID);
+ ann_random(&ann);
+
+ f64 inputs[4][2] = {{0, 0}, {0, 1}, {1, 0}, {1, 1}};
+ f64 targets[4] = {0, 1, 1, 0};
+ f64 output[1] = {0};
+
+ for (int iter = 0; iter < 5000; iter++) {
+ for (int i = 0; i < 4; i++) {
+ ann_propagation_forward(&ann, inputs[i], output);
+ ann_propagation_backward(&ann, inputs[i], output, &targets[i], 0.5);
+ }
+ }
+
+ f64 outputs[4];
+ for (int i = 0; i < 4; i++) {
+ ann_propagation_forward(&ann, inputs[i], &outputs[i]);
+ }
+
+ ASSERT(outputs[0] < 0.5, "XOR(0,0) < 0.5");
+ ASSERT(outputs[1] > 0.5, "XOR(0,1) > 0.5");
+ ASSERT(outputs[2] > 0.5, "XOR(1,0) > 0.5");
+ ASSERT(outputs[3] < 0.5, "XOR(1,1) < 0.5");
+
+ ann_free(&ann);
+}
+
+void test_multi_layer() {
+ ann_t ann = {0};
+ u64 layers[] = {3, 4, 5, 2};
+ ASSERT(ann_init(&ann, 4, layers), "ann_init succeeds");
+ ann_random(&ann);
+
+ ASSERT(ann.layer_neuron_n[0] == 3, "input 3");
+ ASSERT(ann.layer_neuron_n[1] == 4, "hidden1 4");
+ ASSERT(ann.layer_neuron_n[2] == 5, "hidden2 5");
+ ASSERT(ann.layer_neuron_n[3] == 2, "output 2");
+
+ f64 input[3] = {0.1, 0.2, 0.3};
+ f64 output[2] = {0, 0};
+ ann_propagation_forward(&ann, input, output);
+
+ ASSERT(isfinite(output[0]), "multi-layer output[0] is finite");
+ ASSERT(isfinite(output[1]), "multi-layer output[1] is finite");
+
+ ann_free(&ann);
+}
+
+int main() {
+ test_init();
+ test_free();
+ test_copy();
+ test_random();
+ test_forward();
+ test_backward();
+ test_error();
+ test_activations();
+ test_train_numeric();
+ test_xor_training();
+ test_multi_layer();
+
+ printf("test_ann: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_bin.c b/tests/test_bin.c
new file mode 100644
index 0000000..f12f336
--- /dev/null
+++ b/tests/test_bin.c
@@ -0,0 +1,273 @@
+// Tests for bin.h - flat file block storage operations
+#include <stdint.h>
+typedef int32_t bin_key_t;
+
+#define BIN_IMPLEMENTATION
+#include "../include/bin.h"
+
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_bin.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+struct record {
+ bin_key_t key;
+ char _pad[4];
+ double value;
+};
+
+#define REC(k, v) (&((struct record){.key = (k), .value = (v)}))
+
+void cleanup(char const *path) {
+ remove(path);
+}
+
+void test_init_and_close() {
+ FILE *f = bin_init("/tmp/test_bin_init.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+ ASSERT(bin_length(f) == 0, "initial length is 0");
+ ASSERT(bin_block_size(f) == sizeof(struct record), "block size");
+ bin_close(f);
+ cleanup("/tmp/test_bin_init.bin");
+}
+
+void test_append_and_read() {
+ FILE *f = bin_init("/tmp/test_bin_append.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ for (int i = 0; i < 10; i++) {
+ bin_append(f, REC(i, i * 2.5), 1);
+ }
+
+ ASSERT(bin_length(f) == 10, "length after 10 appends");
+
+ struct record buf[10];
+ ASSERT(bin_read(f, 0, buf, 10) == 10, "read 10 records");
+ for (int i = 0; i < 10; i++) {
+ ASSERT(buf[i].key == i, "read key");
+ ASSERT(fabs(buf[i].value - (double)(i * 2.5)) < 1e-9, "read value");
+ }
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_append.bin");
+}
+
+void test_write() {
+ FILE *f = bin_init("/tmp/test_bin_write.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ bin_append(f, REC(0, 0), 1);
+ bin_append(f, REC(1, 1), 1);
+
+ bin_write(f, 0, REC(99, 99), 1);
+
+ struct record r;
+ ASSERT(bin_read(f, 0, &r, 1) == 1, "read 1 record");
+ ASSERT(r.key == 99, "write overwrites");
+ ASSERT(r.value == 99, "write overwrites value");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_write.bin");
+}
+
+void test_insert() {
+ FILE *f = bin_init("/tmp/test_bin_insert.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ bin_append(f, REC(0, 0), 1);
+ bin_append(f, REC(2, 2), 1);
+
+ bin_insert(f, 1, REC(1, 1), 1);
+
+ ASSERT(bin_length(f) == 3, "length after insert");
+
+ struct record buf[3];
+ ASSERT(bin_read(f, 0, buf, 3) == 3, "read 3 records");
+ ASSERT(buf[0].key == 0, "insert at 1 preserves [0]");
+ ASSERT(buf[1].key == 1, "insert at 1 sets [1]");
+ ASSERT(buf[2].key == 2, "insert at 1 preserves [2]");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_insert.bin");
+}
+
+void test_insert_at_end() {
+ FILE *f = bin_init("/tmp/test_bin_insert_end.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ bin_append(f, REC(0, 0), 1);
+
+ bin_insert(f, 1, REC(1, 1), 1);
+
+ ASSERT(bin_length(f) == 2, "length after insert at end");
+
+ struct record buf[2];
+ ASSERT(bin_read(f, 0, buf, 2) == 2, "read 2 records");
+ ASSERT(buf[0].key == 0, "insert at end preserves [0]");
+ ASSERT(buf[1].key == 1, "insert at end sets [1]");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_insert_end.bin");
+}
+
+void test_insert_at_beginning() {
+ FILE *f = bin_init("/tmp/test_bin_insert_begin.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ bin_append(f, REC(1, 1), 1);
+
+ bin_insert(f, 0, REC(0, 0), 1);
+
+ struct record buf[2];
+ ASSERT(bin_read(f, 0, buf, 2) == 2, "read 2 records");
+ ASSERT(buf[0].key == 0, "insert at 0 sets [0]");
+ ASSERT(buf[1].key == 1, "insert at 0 preserves [1]");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_insert_begin.bin");
+}
+
+void test_open() {
+ FILE *f = bin_init("/tmp/test_bin_open.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) {
+ cleanup("/tmp/test_bin_open.bin");
+ return;
+ }
+ bin_append(f, REC(42, 42), 1);
+ bin_close(f);
+
+ f = bin_open("/tmp/test_bin_open.bin");
+ ASSERT(f != NULL, "bin_open returns non-NULL");
+ if (!f) {
+ cleanup("/tmp/test_bin_open.bin");
+ return;
+ }
+ ASSERT(bin_length(f) == 1, "opened file has correct length");
+
+ struct record r;
+ ASSERT(bin_read(f, 0, &r, 1) == 1, "read 1 record");
+ ASSERT(r.key == 42, "read from opened file");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_open.bin");
+}
+
+void test_search() {
+ FILE *f = bin_init("/tmp/test_bin_search.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ for (int i = 0; i < 20; i++) {
+ bin_append(f, REC(i * 5, 0), 1);
+ }
+
+ i64 found = bin_search(f, 25);
+ ASSERT(found == 5, "search finds existing key");
+
+ i64 not_found = bin_search(f, 26);
+ ASSERT(not_found < 0, "search returns negative for missing key");
+
+ i64 idx = bin_fuzzy_index(not_found);
+ ASSERT(idx == 6, "fuzzy_index returns insertion point");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_search.bin");
+}
+
+void test_search_empty() {
+ FILE *f = bin_init("/tmp/test_bin_search_empty.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ i64 result = bin_search(f, 1);
+ ASSERT(result == -1, "search empty returns -1");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_search_empty.bin");
+}
+
+void test_append_multiple() {
+ FILE *f = bin_init("/tmp/test_bin_multi.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ struct record batch[] = {
+ {.key = 10, .value = 10},
+ {.key = 20, .value = 20},
+ {.key = 30, .value = 30},
+ };
+
+ bin_append(f, batch, 3);
+ ASSERT(bin_length(f) == 3, "batch append length");
+
+ struct record buf[3];
+ ASSERT(bin_read(f, 0, buf, 3) == 3, "read 3 records");
+ ASSERT(buf[0].key == 10, "batch[0]");
+ ASSERT(buf[1].key == 20, "batch[1]");
+ ASSERT(buf[2].key == 30, "batch[2]");
+
+ bin_close(f);
+ cleanup("/tmp/test_bin_multi.bin");
+}
+
+void test_fuzzy_index_positive() {
+ u64 idx = bin_fuzzy_index(5);
+ ASSERT(idx == 5, "fuzzy_index passthrough positive");
+}
+
+void test_ok_variants() {
+ FILE *f = bin_init("/tmp/test_bin_ok.bin", sizeof(struct record));
+ ASSERT(f != NULL, "bin_init returns non-NULL");
+ if (!f) return;
+
+ ASSERT(bin_append_ok(f, REC(1, 1), 1), "append_ok returns 1");
+ ASSERT(bin_append_ok(f, NULL, 1) == 0, "append_ok NULL data returns 0");
+ ASSERT(bin_append_ok(NULL, REC(2, 2), 1) == 0, "append_ok NULL file returns 0");
+
+ ASSERT(bin_write_ok(f, 0, REC(99, 99), 1), "write_ok returns 1");
+ ASSERT(bin_write_ok(f, -1, REC(0, 0), 1) == 0, "write_ok negative index returns 0");
+ ASSERT(bin_write_ok(f, 99, REC(0, 0), 1) == 0, "write_ok out-of-range returns 0");
+
+ ASSERT(bin_insert_ok(f, 0, REC(0, 0), 1), "insert_ok returns 1");
+ ASSERT(bin_insert_ok(f, -1, REC(0, 0), 1) == 0, "insert_ok negative index returns 0");
+
+ ASSERT(bin_close(f), "close returns 1");
+ ASSERT(bin_close(NULL) == 0, "close NULL returns 0");
+
+ cleanup("/tmp/test_bin_ok.bin");
+}
+
+int main() {
+ test_init_and_close();
+ test_append_and_read();
+ test_write();
+ test_insert();
+ test_insert_at_end();
+ test_insert_at_beginning();
+ test_open();
+ test_search();
+ test_search_empty();
+ test_append_multiple();
+ test_fuzzy_index_positive();
+ test_ok_variants();
+
+ printf("test_bin: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_buffer.c b/tests/test_buffer.c
new file mode 100644
index 0000000..f6e61dc
--- /dev/null
+++ b/tests/test_buffer.c
@@ -0,0 +1,134 @@
+// Tests for buffer.h - dynamic buffer allocation and resizing
+#define BUFFER_IMPLEMENTATION
+#include "../include/buffer.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_buffer.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_malloc_and_free() {
+ void *buf = buffer_malloc(10, sizeof(int));
+ ASSERT(buf != NULL, "buffer_malloc returns non-NULL");
+ buffer_free(buf);
+}
+
+void test_capacity() {
+ void *buf = buffer_malloc(10, sizeof(int));
+ ASSERT(buffer_capacity(buf) == 10, "initial capacity");
+ buffer_free(buf);
+}
+
+void test_count() {
+ void *buf = buffer_malloc(10, sizeof(int));
+ ASSERT(buffer_count(buf) == 0, "initial count is 0");
+ buffer_free(buf);
+}
+
+void test_resize_grow() {
+ void *buf = buffer_malloc(10, sizeof(int));
+ ASSERT(buf != NULL, "buffer_malloc returns non-NULL");
+ if (!buf) return;
+ buffer_meta_t *meta = (buffer_meta_t *)_buffer_to_meta(buf);
+ meta->i = 9;
+
+ void *new_buf = buffer_resize(buf);
+ meta = (buffer_meta_t *)_buffer_to_meta(new_buf);
+ ASSERT(meta->n == 20, "capacity doubles when ratio > 0.8");
+
+ buffer_free(new_buf);
+}
+
+void test_resize_shrink() {
+ void *buf = buffer_malloc(100, sizeof(int));
+ ASSERT(buf != NULL, "buffer_malloc returns non-NULL");
+ if (!buf) return;
+ buffer_meta_t *meta = (buffer_meta_t *)_buffer_to_meta(buf);
+ meta->i = 5;
+
+ void *new_buf = buffer_resize(buf);
+ meta = (buffer_meta_t *)_buffer_to_meta(new_buf);
+ ASSERT(meta->n == 50, "capacity halves when ratio < 0.2 and n > 32");
+
+ buffer_free(new_buf);
+}
+
+void test_resize_no_change() {
+ void *buf = buffer_malloc(32, sizeof(int));
+ ASSERT(buf != NULL, "buffer_malloc returns non-NULL");
+ if (!buf) return;
+ buffer_meta_t *meta = (buffer_meta_t *)_buffer_to_meta(buf);
+ meta->i = 10;
+
+ void *new_buf = buffer_resize(buf);
+ ASSERT(new_buf == buf, "no realloc when capacity unchanged");
+
+ buffer_free(buf);
+}
+
+void test_resize_small_no_shrink() {
+ void *buf = buffer_malloc(30, sizeof(int));
+ ASSERT(buf != NULL, "buffer_malloc returns non-NULL");
+ if (!buf) return;
+ buffer_meta_t *meta = (buffer_meta_t *)_buffer_to_meta(buf);
+ meta->i = 1;
+
+ void *new_buf = buffer_resize(buf);
+ meta = (buffer_meta_t *)_buffer_to_meta(new_buf);
+ ASSERT(meta->n == 30, "does not shrink below 32");
+
+ buffer_free(new_buf);
+}
+
+void test_data_integrity() {
+ int *buf = (int *)buffer_malloc(10, sizeof(int));
+ for (int i = 0; i < 10; i++) {
+ buf[i] = i * 100;
+ }
+
+ buffer_meta_t *meta = (buffer_meta_t *)_buffer_to_meta(buf);
+ meta->i = 9;
+
+ int *new_buf = (int *)buffer_resize(buf);
+ for (int i = 0; i < 10; i++) {
+ ASSERT(new_buf[i] == i * 100, "data preserved after resize");
+ }
+
+ buffer_free(new_buf);
+}
+
+void test_different_sizes() {
+ void *buf1 = buffer_malloc(5, sizeof(char));
+ void *buf2 = buffer_malloc(5, sizeof(int));
+ void *buf3 = buffer_malloc(5, sizeof(double));
+ ASSERT(buffer_capacity(buf1) == 5, "char capacity");
+ ASSERT(buffer_capacity(buf2) == 5, "int capacity");
+ ASSERT(buffer_capacity(buf3) == 5, "double capacity");
+ buffer_free(buf1);
+ buffer_free(buf2);
+ buffer_free(buf3);
+}
+
+int main() {
+ test_malloc_and_free();
+ test_capacity();
+ test_count();
+ test_resize_grow();
+ test_resize_shrink();
+ test_resize_no_change();
+ test_resize_small_no_shrink();
+ test_data_integrity();
+ test_different_sizes();
+
+ printf("test_buffer: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_clio.c b/tests/test_clio.c
new file mode 100644
index 0000000..535df1d
--- /dev/null
+++ b/tests/test_clio.c
@@ -0,0 +1,884 @@
+/* test_clio.c — standalone test suite for clio.h (+ escape.h).
+ * Zero dependencies: single TU, no linked libraries (defines
+ * ESCAPE_IMPLEMENTATION to pull in the esc_* symbols). */
+#define ESCAPE_IMPLEMENTATION
+#include "clio.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+/* Capture stdout (fd 1) into a malloc'd buffer across the test body. */
+typedef struct cap {
+ int pfd[2];
+ int saved;
+} cap_t;
+
+/* dup2() returns the target fd (STDOUT_FILENO == 1) on success, -1 on
+ * failure — compare against -1, not 0. */
+static int cap_begin(cap_t *c) {
+ if (pipe(c->pfd) != 0)
+ return -1;
+ c->saved = dup(STDOUT_FILENO);
+ if (c->saved < 0)
+ return -1;
+ if (dup2(c->pfd[1], STDOUT_FILENO) == -1) {
+ close(c->pfd[0]);
+ close(c->pfd[1]);
+ close(c->saved);
+ return -1;
+ }
+ close(c->pfd[1]);
+ return 0;
+}
+
+static char *cap_end(cap_t *c, size_t *len) {
+ fflush(stdout);
+ if (dup2(c->saved, STDOUT_FILENO) == -1) {
+ close(c->saved);
+ return NULL;
+ }
+ close(c->saved);
+ size_t cap = 256;
+ size_t n = 0;
+ char *buf = (char *) malloc(cap);
+ char tmp[1024];
+ ssize_t r;
+ while ((r = read(c->pfd[0], tmp, sizeof tmp)) > 0) {
+ if (n + (size_t) r > cap) {
+ cap = (cap + (size_t) r) * 2;
+ buf = (char *) realloc(buf, cap);
+ }
+ memcpy(buf + n, tmp, (size_t) r);
+ n += (size_t) r;
+ }
+ close(c->pfd[0]);
+ buf[n] = '\0'; /* callers use both strlen() and len */
+ *len = n;
+ return buf;
+}
+
+static int contains(const char *hay, size_t len, const char *needle) {
+ size_t nl = strlen(needle);
+ if (nl > len)
+ return 0;
+ for (size_t i = 0; i + nl <= len; i++)
+ if (memcmp(hay + i, needle, nl) == 0)
+ return 1;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text: UTF-8 + width */
+/* ------------------------------------------------------------------ */
+
+static void test_utf8(void) {
+ u32 cp;
+ char out[8];
+ CHECK(clio_utf8_next("a", &cp) == 1 && cp == 'a', "ascii decode");
+ CHECK(clio_utf8_next("\xC3\xA9", &cp) == 2 && cp == 0xE9, "latin1 decode");
+ CHECK(clio_utf8_next("\xE4\xB8\xAD", &cp) == 3 && cp == 0x4E2D,
+ "cjk decode");
+ CHECK(clio_utf8_next("\xF0\x9F\x98\x80", &cp) == 4 && cp == 0x1F600,
+ "emoji decode");
+ /* truncated sequence: consumes what is left */
+ CHECK(clio_utf8_next("\xE4\xB8", &cp) == 2, "truncated decode");
+ CHECK(clio_utf8_enc(0x4E2D, out) == 3 && memcmp(out, "\xE4\xB8\xAD", 3) == 0,
+ "cjk encode");
+ CHECK(clio_utf8_enc(0x1F600, out) == 4, "emoji encode");
+ CHECK(clio_utf8_enc(0xD800, out) == -1, "surrogate rejected");
+ CHECK(clio_utf8_enc(0x110000, out) == -1, "out of range rejected");
+
+ CHECK(clio_text_width("abc") == 3, "ascii width");
+ CHECK(clio_text_width("h\xC3\xA9llo") == 5, "latin width");
+ CHECK(clio_text_width("\xE4\xB8\xAD\xE6\x96\x87") == 4, "cjk width");
+ CHECK(clio_text_width("\xF0\x9F\x98\x80") == 2, "emoji width");
+ CHECK(clio_cp_width('\n') == 0, "newline zero width");
+
+ const char *s = "ab\xE4\xB8\xAD" "c"; /* a b 中 c */
+ CHECK(clio_char_start(s, 4) == 2, "char_start cjk");
+ CHECK(clio_char_start(s, 2) == 2, "char_start at boundary");
+ CHECK(clio_char_start(s, 1) == 1, "char_start ascii");
+ CHECK(clio_char_end(s, 6, 2) == 5, "char_end cjk");
+ CHECK(clio_char_end(s, 6, 5) == 6, "char_end last");
+
+ /* semantics verified against bash/readline Ctrl+W via pty:
+ * "foo bar baz"@12 -> 7, "foo bar"@8 -> 3, "a_b c"@5 -> 3 */
+ CHECK(clio_word_back("foo bar baz", 12) == 7, "word_back word");
+ CHECK(clio_word_back("foo bar", 8) == 3, "word_back across spaces");
+ CHECK(clio_word_back("a_b c", 5) == 3, "word_back underscore");
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas */
+/* ------------------------------------------------------------------ */
+
+static void test_buf(void) {
+ clio_buf b;
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ s.bg = 238;
+ s.attr = CLIO_BOLD | CLIO_REVERSE;
+
+ CHECK(clio_buf_init(&b, 3, 8) == 0, "buf init");
+ CHECK(b.rows == 3 && b.cols == 8, "buf dims");
+
+ /* default cell is blank + default style */
+ clio_cell *c0 = b.cells;
+ CHECK(c0->ch == 0 && c0->fg == 0xFFFF && c0->bg == 0xFFFF && c0->attr == 0,
+ "default cell");
+
+ CHECK(clio_buf_put(&b, 0, 0, "hi", s, 0) == 2, "put ascii");
+ CHECK(b.cells[0].ch == 'h' && b.cells[1].ch == 'i', "put stored");
+ CHECK(b.cells[0].fg == 248 && b.cells[0].bg == 238, "put style");
+ CHECK(b.cells[0].attr == (CLIO_BOLD | CLIO_REVERSE), "put attr");
+ CHECK(b.cells[2].ch == 0, "put bounds");
+
+ /* clipping: no wrap */
+ CHECK(clio_buf_put(&b, 0, 6, "abc", s, 0) == 2, "clip no wrap");
+ CHECK(b.cells[7].ch == 'b', "clip stored edge");
+ clio_buf_clear(&b);
+
+ /* wrapping onto next row */
+ CHECK(clio_buf_put(&b, 0, 6, "abcd", s, 1) == 4, "wrap put");
+ CHECK(b.cells[7].ch == 'b', "wrap first row edge");
+ CHECK(b.cells[8].ch == 'c' && b.cells[9].ch == 'd', "wrap second row");
+
+ clio_buf_clear(&b);
+
+ /* newline moves to next row at col 0 */
+ clio_buf_put(&b, 0, 0, "a\nb", s, 1);
+ CHECK(b.cells[0].ch == 'a' && b.cells[8].ch == 'b', "newline put");
+
+ clio_buf_clear(&b);
+
+ /* wide char occupies two cells */
+ clio_buf_put(&b, 0, 0, "\xE4\xB8\xAD\xE6\x96\x87", s, 1);
+ CHECK(b.cells[0].ch == 0x4E2D && b.cells[1].ch == 0, "wide first cell");
+ CHECK(b.cells[2].ch == 0x6587 && b.cells[3].ch == 0, "wide second cell");
+
+ clio_buf_clear(&b);
+
+ /* zero-width chars consume no cell */
+ clio_buf_put(&b, 0, 0, "\xE2\x80\x8Bx", s, 1); /* ZWSP + x */
+ CHECK(b.cells[0].ch == 'x', "zero width skips");
+
+ clio_buf_clear(&b);
+
+ /* fill + clear region */
+ clio_buf_fill_region(&b, 0, 0, 1, 3, '*', s);
+ CHECK(b.cells[0].ch == '*' && b.cells[3].ch == '*', "fill row 0");
+ CHECK(b.cells[8].ch == '*' && b.cells[11].ch == '*', "fill row 1");
+ CHECK(b.cells[4].ch == 0, "fill bounds");
+ clio_buf_clear_region(&b, 0, 1, 1, 2);
+ CHECK(b.cells[0].ch == '*' && b.cells[1].ch == 0 && b.cells[2].ch == 0,
+ "clear region");
+ CHECK(b.cells[10].ch == 0 && b.cells[11].ch == '*', "clear region row 1");
+ clio_buf_clear(&b);
+
+ /* box 2x4 in top-left */
+ clio_buf_box(&b, 0, 0, 4, 2, '#', s);
+ CHECK(b.cells[0].ch == '#' && b.cells[3].ch == '#', "box top");
+ CHECK(b.cells[8].ch == '#' && b.cells[11].ch == '#', "box bottom");
+ clio_buf_clear(&b);
+
+ /* scroll up: rows 0..2 shift, bottom becomes blank */
+ clio_buf_put(&b, 0, 0, "aa", s, 0);
+ clio_buf_put(&b, 1, 0, "bb", s, 0);
+ clio_buf_put(&b, 2, 0, "cc", s, 0);
+ clio_buf_scroll_up(&b, 0, 0, 2, 1);
+ CHECK(b.cells[0].ch == 'b' && b.cells[8].ch == 'c', "scroll moved");
+ CHECK(b.cells[16].ch == 0, "scroll bottom blank");
+
+ clio_buf_free(&b);
+ CHECK(b.cells == NULL, "buf free");
+
+ /* init with zero dims clamps to 1x1 */
+ CHECK(clio_buf_init(&b, 0, 0) == 0 && b.rows == 1 && b.cols == 1,
+ "buf init clamps");
+ clio_buf_free(&b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key decoder */
+/* ------------------------------------------------------------------ */
+
+static size_t decode(clio_keydec *d, const char *in, clio_key *k, size_t cap) {
+ clio_keydec_init(d);
+ return clio_keydec_feed(d, (const unsigned char *) in, strlen(in), k, cap);
+}
+
+static void test_keydec(void) {
+ clio_keydec d;
+ clio_key k[16];
+ size_t n;
+
+ n = decode(&d, "\x1B[C", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "csi right");
+ n = decode(&d, "\x1B[D", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_LEFT, "csi left");
+ n = decode(&d, "\x1B[A", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "csi up");
+ n = decode(&d, "\x1B[B", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DOWN, "csi down");
+ n = decode(&d, "\x1B[H", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi home");
+ n = decode(&d, "\x1B[F", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi end");
+ n = decode(&d, "\x1B[Z", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_BACKTAB, "csi backtab");
+ n = decode(&d, "\x1B[1~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 1~ home");
+ n = decode(&d, "\x1B[7~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 7~ home");
+ n = decode(&d, "\x1B[3~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "csi 3~ delete");
+ n = decode(&d, "\x1B[4~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi 4~ end");
+ n = decode(&d, "\x1B[5~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEUP, "csi 5~ pageup");
+ n = decode(&d, "\x1B[6~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEDOWN, "csi 6~ pagedown");
+
+ n = decode(&d, "\x1BOA", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "ss3 up");
+ n = decode(&d, "\x1BOF", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "ss3 end");
+
+ n = decode(&d, "\t\r\x7F\x08", k, 16);
+ CHECK(n == 4 && k[0].kind == CLIOK_TAB && k[1].kind == CLIOK_ENTER &&
+ k[2].kind == CLIOK_BACKSPACE && k[3].kind == CLIOK_BACKSPACE,
+ "tab enter backspace del8");
+
+ n = decode(&d, "\x01\x03\x04\x05\x0B\x0C\x15\x17\x19\x1A", k, 16);
+ CHECK(n == 10 && k[0].kind == CLIOK_CTRL_A && k[1].kind == CLIOK_CTRL_C &&
+ k[2].kind == CLIOK_CTRL_D && k[3].kind == CLIOK_CTRL_E &&
+ k[4].kind == CLIOK_CTRL_K && k[5].kind == CLIOK_CTRL_L &&
+ k[6].kind == CLIOK_CTRL_U && k[7].kind == CLIOK_CTRL_W &&
+ k[8].kind == CLIOK_CTRL_Y && k[9].kind == CLIOK_CTRL_Z,
+ "ctrl letters");
+
+ n = decode(&d, "\x07", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CTRL_G, "ctrl g");
+ n = decode(&d, "\x1B", k, 16);
+ CHECK(n == 0, "bare esc not emitted yet");
+ CHECK(clio_keydec_flush(&d, &k[0]) == 1 && k[0].kind == CLIOK_ESC,
+ "esc flush");
+
+ n = decode(&d, "h\xC3\xA9\xE4\xB8\xAD", k, 16);
+ CHECK(n == 3 && k[0].cp == 'h' && k[1].cp == 0xE9 && k[2].cp == 0x4E2D,
+ "utf8 keys");
+
+ /* kitty protocol */
+ n = decode(&d, "\x1B[13u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ENTER, "kitty enter");
+ n = decode(&d, "\x1B[127u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "kitty delete");
+ n = decode(&d, "\x1B[9u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_TAB, "kitty tab");
+ n = decode(&d, "\x1B[27u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ESC, "kitty esc");
+
+ /* bracketed paste: LF inside paste is a char; start/end emit no key */
+ n = decode(&d, "\x1B[200~a\nb\x1B[201~", k, 16);
+ CHECK(n == 3 && k[0].cp == 'a' && k[1].cp == '\n' && k[2].cp == 'b',
+ "bracketed paste");
+
+ /* bare LF outside paste: dead key */
+ n = decode(&d, "\n", k, 16);
+ CHECK(n == 0, "bare lf dead");
+
+ /* split feeds: escape sequence across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\x1B", 1, k, 16);
+ CHECK(n == 0, "split esc");
+ n = clio_keydec_feed(&d, (const unsigned char *) "[", 1, k, 16);
+ CHECK(n == 0, "split [");
+ n = clio_keydec_feed(&d, (const unsigned char *) "C", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "split C");
+
+ /* split UTF-8 across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xC3", 1, k, 16);
+ CHECK(n == 0, "split utf8 lead");
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xA9", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CHAR && k[0].cp == 0xE9, "split utf8");
+
+ /* unknown CSI emits UNKNOWN so the app can react (e.g. beep) */
+ n = decode(&d, "\x1B[15~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UNKNOWN, "unknown csi -> UNKNOWN");
+ /* cursor position report swallowed */
+ n = decode(&d, "\x1B[12;40R", k, 16);
+ CHECK(n == 0, "cpr swallowed");
+}
+
+/* ------------------------------------------------------------------ */
+/* Color policy */
+/* ------------------------------------------------------------------ */
+
+static void setenv0(const char *k, const char *v) {
+ if (v)
+ setenv(k, v, 1);
+ else
+ unsetenv(k);
+}
+
+static void test_color_policy(void) {
+ setenv0("CLICOLOR_FORCE", NULL);
+ setenv0("NO_COLOR", NULL);
+ setenv0("CI", NULL);
+ setenv0("TERM", NULL);
+ CHECK(clio_color_policy(1) == 1, "tty on");
+ CHECK(clio_color_policy(0) == 0, "notty off");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 0, "NO_COLOR off");
+ setenv0("NO_COLOR", NULL);
+ setenv0("TERM", "dumb");
+ CHECK(clio_color_policy(1) == 0, "TERM=dumb off");
+ setenv0("TERM", NULL);
+ setenv0("CI", "true");
+ CHECK(clio_color_policy(1) == 0, "CI off");
+ setenv0("CI", NULL);
+ setenv0("CLICOLOR_FORCE", "1");
+ CHECK(clio_color_policy(0) == 1, "force on, notty");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 1, "force beats NO_COLOR");
+ setenv0("CLICOLOR_FORCE", "0");
+ CHECK(clio_color_policy(1) == 0, "force 0 off");
+ setenv0("CLICOLOR_FORCE", NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* Style emission */
+/* ------------------------------------------------------------------ */
+
+static void test_fstyle(void) {
+ cap_t c;
+ clio_style s = clio_style_default();
+ s.fg = 38;
+ s.bg = 48;
+ s.attr = CLIO_BOLD | CLIO_UNDERLINE;
+
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_fstyle(stdout, s);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL && len > 0, "fstyle wrote");
+ if (out) {
+ /* exact sequence: reset, bold, underline, fg, bg (in that order) */
+ CHECK(contains(out, len, ESC_RESET) == 1, "fstyle reset");
+ CHECK(contains(out, len, ESC_BOLD) == 1, "fstyle bold");
+ CHECK(contains(out, len, ESC_UNDERLINE) == 1, "fstyle underline");
+ CHECK(contains(out, len, ESC_COLOR_FG_256(38)) == 1, "fstyle fg256");
+ CHECK(contains(out, len, ESC_COLOR_BG_256(48)) == 1, "fstyle bg256");
+ CHECK(contains(out, len, "48;5;48m") == 1, "bg uses 48 not 38");
+ CHECK(contains(out, len, ESC_ITALIC) == 0, "no italic");
+ /* reset first, fg before bg */
+ const char *rp = strstr(out, ESC_RESET);
+ const char *fp = strstr(out, ESC_COLOR_FG_256(38));
+ const char *bp = strstr(out, ESC_COLOR_BG_256(48));
+ CHECK(rp && fp && bp && rp < fp && fp < bp, "fstyle order");
+ free(out);
+ }
+
+ /* default style: only reset */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_fstyle(stdout, clio_style_default());
+ out = cap_end(&c, &len);
+ CHECK(out != NULL && strcmp(out, ESC_RESET) == 0, "default = reset only");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Flush (non-tty plain + tty diff) */
+/* ------------------------------------------------------------------ */
+
+static void test_flush_plain(void) {
+ clio_term t;
+ clio_term_init(&t);
+ clio_buf b;
+ clio_buf_init(&b, 2, 8);
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ clio_buf_put(&b, 0, 0, "hi", s, 0);
+ clio_buf_put(&b, 1, 0, "yo", s, 0);
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_flush(&t, &b);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL, "cap end");
+ CHECK(len > 0, "plain wrote");
+ CHECK(contains(out, len, "hi"), "plain row 0");
+ CHECK(contains(out, len, "yo"), "plain row 1");
+ CHECK(contains(out, len, "\x1B") == 0, "plain has no escapes");
+ CHECK(contains(out, len, "hi\nyo\n") == 1, "plain exact lines");
+ free(out);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+static void test_flush_diff(void) {
+ clio_term t;
+ clio_term_init(&t);
+ /* force tty mode + fixed size for a deterministic test */
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 8;
+
+ clio_buf a;
+ clio_buf_init(&a, 3, 8);
+ clio_style s = clio_style_default();
+ clio_buf_put(&a, 0, 0, "row0", s, 0);
+ clio_buf_put(&a, 1, 0, "row1", s, 0);
+ clio_buf_put(&a, 2, 0, "row2", s, 0);
+
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_flush(&t, &a);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "first flush clears");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "first flush row 1 pos");
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "first flush row 3 pos");
+ CHECK(contains(out, len, "row0") == 1, "first flush content");
+ free(out);
+
+ /* change only row 2 */
+ clio_buf b;
+ clio_buf_init(&b, 3, 8);
+ clio_buf_put(&b, 0, 0, "row0", s, 0);
+ clio_buf_put(&b, 1, 0, "row1", s, 0);
+ clio_buf_put(&b, 2, 0, "CHG2", s, 0);
+
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_flush(&t, &b);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "diff row 3 pos");
+ CHECK(contains(out, len, "CHG2") == 1, "diff row 2 content");
+ CHECK(contains(out, len, "\x1B[1;1H") == 0, "diff skips row 1");
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 0, "diff no clear");
+ CHECK(contains(out, len, "row0") == 0, "diff skips row 0 content");
+ free(out);
+
+ /* size change forces full repaint */
+ clio_buf d2;
+ clio_buf_init(&d2, 2, 4);
+ clio_buf_put(&d2, 0, 0, "xy", s, 0);
+ clio_buf_put(&d2, 1, 0, "zw", s, 0);
+ clio_buf_free(&t.last);
+ t.has_last = 0;
+ t.rows = 2;
+ t.cols = 4;
+ CHECK(cap_begin(&c) == 0, "cap begin 3");
+ clio_flush(&t, &d2);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "resize full repaint");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "resize row 1");
+ free(out);
+
+ clio_buf_free(&a);
+ clio_buf_free(&b);
+ clio_buf_free(&d2);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Term toggles emit the right codes */
+/* ------------------------------------------------------------------ */
+
+static void test_term_toggles(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_term_alt_on(&t);
+ clio_term_cursor_hide(&t);
+ clio_term_paste_on(&t);
+ clio_term_kitty_on(&t);
+ clio_term_bell(&t);
+ clio_term_alt_on(&t); /* idempotent: no second emission */
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on once");
+ CHECK(contains(out, len, ESC_CURSOR_HIDE) == 1, "cursor hide");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_ON) == 1, "paste on");
+ CHECK(contains(out, len, ESC_KITTY_ON) == 1, "kitty on");
+ CHECK(contains(out, len, ESC_BELL) == 1, "bell");
+ free(out);
+
+ /* shutdown restores everything, in reverse, once each */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_term_shutdown(&t);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_KITTY_OFF) == 1, "kitty off");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_OFF) == 1, "paste off");
+ CHECK(contains(out, len, ESC_CURSOR_SHOW) == 1, "cursor show");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_OFF) == 1, "alt off");
+ /* order: kitty first, alt last */
+ if (out) {
+ const char *kp = strstr(out, ESC_KITTY_OFF);
+ const char *ap = strstr(out, ESC_ALT_SCREEN_OFF);
+ CHECK(kp && ap && kp < ap, "shutdown order");
+ }
+ free(out);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline (scripted key source) */
+/* ------------------------------------------------------------------ */
+
+typedef struct keys {
+ clio_key *ks;
+ size_t n;
+ size_t i;
+} keys_t;
+
+static int keys_next(void *ud, clio_key *k, int timeout_ms) {
+ (void) timeout_ms;
+ keys_t *s = (keys_t *) ud;
+ if (s->i >= s->n)
+ return 0;
+ *k = s->ks[s->i++];
+ return 1;
+}
+
+static void push(keys_t *s, int kind, u32 cp) {
+ s->ks[s->n].kind = kind;
+ s->ks[s->n].cp = cp;
+ s->n++;
+}
+
+static char *run_readline(keys_t *s, int echo, int rows, int cols) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = (size_t) rows;
+ t.cols = (size_t) cols;
+ clio_buf b;
+ clio_buf_init(&b, (size_t) rows, (size_t) cols);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ s->i = 0;
+ char *r = clio_readline(&t, &b, 0, "> ", p, st, echo, keys_next, s, 0);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+ return r;
+}
+
+static void test_readline(void) {
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ char *r;
+
+ /* type "ab", backspace, "c" -> "ac" */
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r != NULL && strcmp(r, "ac") == 0, "insert backspace");
+ free(r);
+
+ /* left arrow + insert: caret between a and c, x goes there -> "axc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "axc") == 0, "left insert");
+ free(r);
+
+ /* ctrl+a, 'z' -> "zaxc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CHAR, 'z');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "zaxc") == 0, "ctrl+a insert");
+ free(r);
+
+ /* ctrl+u clears line -> "" (submit empty) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CTRL_U, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+u clears all");
+
+ /* ctrl+a then ctrl+k kills whole line -> empty submit */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+k empty after");
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "ab") == 0, "ctrl+k kills rest");
+ free(r);
+
+ /* ctrl+w deletes a word */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'h');
+ push(&s, CLIOK_CHAR, 'e');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, ' ');
+ push(&s, CLIOK_CHAR, 'w');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, 'r');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'd');
+ push(&s, CLIOK_CTRL_W, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ /* matches bash/readline: the preceding space is killed too */
+ CHECK(r && strcmp(r, "hello") == 0, "ctrl+w word");
+ free(r);
+
+ /* ctrl+c cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CTRL_C, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+c cancels");
+
+ /* escape cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_ESC, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "esc cancels");
+
+ /* ctrl+d on empty submits */
+ s.n = 0;
+ push(&s, CLIOK_CTRL_D, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+d empty submits");
+
+ /* ctrl+d deletes the char after the caret (left first) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_D, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "ctrl+d delete");
+ free(r);
+
+ /* delete key removes forward char (caret in the middle) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_DELETE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "delete forward");
+ free(r);
+
+ /* utf8 input round trip */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 0x4E2D); /* 中 */
+ push(&s, CLIOK_CHAR, 0x6587); /* 文 */
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "\xE4\xB8\xAD\xE6\x96\x87") == 0, "utf8 input");
+ free(r);
+
+ /* echo=0: stars rendered, but value is real text */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 0, 24, 80);
+ CHECK(r && strcmp(r, "abc") == 0, "echo off value");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Long lines: no wrap, window scrolls, backspace spans the "wrap" */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_long(void) {
+ /* 10 cols, prompt "> " => 8-cell text area. */
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 10;
+ clio_buf b;
+ clio_buf_init(&b, 3, 10);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ size_t col;
+
+ /* caret at end: caret pinned to last col, tail visible, head scrolled */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 1);
+ CHECK(col == 9, "caret pinned last col");
+ CHECK(b.cells[2].ch == 'f' && b.cells[8].ch == 'l', "tail window");
+ CHECK(b.cells[9].ch == 0, "nothing past caret");
+
+ /* one backspace of scroll: window slides left by one */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 11, st, 1);
+ CHECK(col == 9, "caret stays pinned");
+ CHECK(b.cells[2].ch == 'e' && b.cells[9].ch == 'l', "window slid");
+
+ /* caret in the middle: anchored at 0, right edge clipped */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 3, st, 1);
+ CHECK(col == 5, "caret middle");
+ CHECK(b.cells[2].ch == 'a' && b.cells[9].ch == 'h', "head window clipped");
+
+ /* wide chars: 8 CJK = 16 cells; window must start on a char
+ * boundary and the caret clamp inside the row */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ static const char cjk[] = "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD"
+ "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD";
+ clio_str_ins(&txt, 0, cjk, sizeof cjk - 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, sizeof cjk - 1, st, 1);
+ CHECK(col == 9, "wide caret clamped");
+ CHECK(b.cells[2].ch == 0x4E2D && b.cells[8].ch == 0x4E2D,
+ "wide window on boundary");
+
+ /* noecho: same windowing over star count */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 0);
+ CHECK(col == 9, "star caret pinned");
+ CHECK(b.cells[2].ch == '*' && b.cells[8].ch == '*' && b.cells[9].ch == 0,
+ "star window");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+
+ /* integration: type past the edge, backspace across the scroll
+ * boundary — the logical result must be exact */
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ char *r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghi") == 0, "backspace across wrap");
+ free(r);
+
+ /* left-arrow into the scrolled region, then backspace */
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghijl") == 0, "left+backspace across wrap");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline canvas paint */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_paint(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 4;
+ t.cols = 20;
+ clio_buf b;
+ clio_buf_init(&b, 4, 20);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+
+ /* type "ab", caret after 'b' (col 4) */
+ clio_str_ins(&txt, 0, "ab", 2);
+ size_t col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret col echo");
+ CHECK(b.cells[0].ch == '>' && b.cells[1].ch == ' ', "prompt painted");
+ CHECK(b.cells[2].ch == 'a' && b.cells[3].ch == 'b', "text painted");
+
+ /* echo off: stars */
+ clio_buf_clear(&b);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 0);
+ CHECK(col == 4, "caret col noecho");
+ CHECK(b.cells[2].ch == '*' && b.cells[3].ch == '*', "stars painted");
+
+ /* caret in the middle */
+ clio_str_ins(&txt, 0, "ab", 2);
+ clio_str_ins(&txt, 1, "X", 1);
+ clio_str_ins(&txt, 2, "c", 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret middle");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+int main(void) {
+ test_utf8();
+ test_buf();
+ test_keydec();
+ test_color_policy();
+ test_fstyle();
+ test_flush_plain();
+ test_flush_diff();
+ test_term_toggles();
+ test_readline();
+ test_readline_long();
+ test_readline_paint();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/tests/test_direct.c b/tests/test_direct.c
new file mode 100644
index 0000000..7f0e192
--- /dev/null
+++ b/tests/test_direct.c
@@ -0,0 +1,157 @@
+// Tests for direct.h - computation graph connectivity and updates
+#include <stdio.h>
+typedef double direct_value_t;
+#define DT_IMPLEMENTATION
+#include "../include/direct.h"
+
+#include <math.h>
+
+#define EPS 1e-9
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_direct.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_FLOAT_EQ(a, b, msg) \
+ do { \
+ if (fabs((a) - (b)) > EPS) { \
+ fprintf(stderr, "FAIL: %s (got %f, expected %f) (test_direct.c:%d)\n", \
+ msg, (double)(a), (double)(b), __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_connect() {
+ node_t a = {NULL, 1, 0, &dt_one};
+ node_t b = {NULL, 2, 0, &dt_add};
+
+ ASSERT(dt_connect(&b, &a), "connect returns 1 on success");
+ ASSERT(b.edge != NULL, "connect sets edge");
+ if (!b.edge) { dt_clear_edges(&b); return; }
+ ASSERT(b.edge->node == &a, "connect sets correct node");
+
+ dt_clear_edges(&b);
+}
+
+void test_disconnect() {
+ node_t a = {NULL, 1, 0, &dt_one};
+ node_t b = {NULL, 2, 0, &dt_add};
+
+ dt_connect(&b, &a);
+ dt_disconnect(&b, &a);
+ ASSERT(b.edge == NULL, "disconnect removes edge");
+}
+
+void test_connect_ok_null() {
+ node_t a = {NULL, 1, 0, &dt_one};
+ node_t b = {NULL, 2, 0, &dt_add};
+
+ ASSERT(!dt_connect_ok(NULL, &a), "connect_ok NULL to returns 0");
+ ASSERT(!dt_connect_ok(&b, NULL), "connect_ok NULL from returns 0");
+ ASSERT(!dt_connect_ok(NULL, NULL), "connect_ok both NULL returns 0");
+}
+
+void test_add() {
+ node_t a = {NULL, 0, 0, &dt_one};
+ node_t b = {NULL, 0, 0, &dt_one};
+ node_t sum = {NULL, 0, 0, &dt_add};
+
+ dt_connect(&sum, &a);
+ dt_connect(&sum, &b);
+ dt_update(&sum);
+
+ ASSERT_FLOAT_EQ(a.x, 1, "dt_one sets a to 1");
+ ASSERT_FLOAT_EQ(b.x, 1, "dt_one sets b to 1");
+ ASSERT_FLOAT_EQ(sum.x, 2, "add sums edges: 0+1+1");
+
+ dt_clear_edges(&sum);
+}
+
+void test_subtract() {
+ node_t a = {NULL, 0, 0, &dt_one};
+ node_t b = {NULL, 0, 0, &dt_one};
+ node_t c = {NULL, 0, 0, &dt_one};
+ node_t diff = {NULL, 0, 0, &dt_subtract};
+
+ dt_connect(&diff, &a);
+ dt_connect(&diff, &b);
+ dt_connect(&diff, &c);
+ dt_update(&diff);
+
+ ASSERT_FLOAT_EQ(diff.x, -3, "subtract: 0 - 1 - 1 - 1");
+
+ dt_clear_edges(&diff);
+}
+
+void test_multiple_connections() {
+ node_t a = {NULL, 0, 0, &dt_one};
+ node_t b = {NULL, 0, 0, &dt_one};
+ node_t c = {NULL, 0, 0, &dt_one};
+ node_t sum = {NULL, 5, 0, &dt_add};
+
+ dt_connect(&sum, &a);
+ dt_connect(&sum, &b);
+ dt_connect(&sum, &c);
+ dt_update(&sum);
+
+ ASSERT_FLOAT_EQ(sum.x, 8, "add: 5 + 1 + 1 + 1");
+
+ dt_clear_edges(&sum);
+}
+
+void test_chained_addition() {
+ node_t a = {NULL, 0, 0, &dt_one};
+ node_t b = {NULL, 0, 0, &dt_one};
+ node_t c = {NULL, 0, 0, &dt_one};
+
+ node_t ab = {NULL, 0, 0, &dt_add};
+ dt_connect(&ab, &a);
+ dt_connect(&ab, &b);
+
+ node_t abc = {NULL, 10, 0, &dt_add};
+ dt_connect(&abc, &ab);
+ dt_connect(&abc, &c);
+
+ dt_update(&abc);
+ ASSERT_FLOAT_EQ(ab.x, 2, "ab: 0+1+1=2");
+ ASSERT_FLOAT_EQ(abc.x, 13, "abc: 10+2+1=13");
+
+ dt_clear_edges(&ab);
+ dt_clear_edges(&abc);
+}
+
+void test_clear_edges() {
+ node_t a = {NULL, 1, 0, &dt_one};
+ node_t b = {NULL, 0, 0, &dt_add};
+
+ dt_connect(&b, &a);
+ dt_clear_edges(&b);
+ ASSERT(b.edge == NULL, "clear_edges sets edge to NULL");
+}
+
+void test_single_node() {
+ node_t n = {NULL, 42, 0, &dt_one};
+ dt_update(&n);
+ ASSERT_FLOAT_EQ(n.x, 1, "single node with dt_one");
+}
+
+int main() {
+ test_connect();
+ test_disconnect();
+ test_add();
+ test_subtract();
+ test_multiple_connections();
+ test_chained_addition();
+ test_clear_edges();
+ test_single_node();
+ test_connect_ok_null();
+
+ printf("test_direct: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_dotagents.c b/tests/test_dotagents.c
new file mode 100644
index 0000000..4b977c7
--- /dev/null
+++ b/tests/test_dotagents.c
@@ -0,0 +1,524 @@
+/* test_dotagents.c — standalone test suite for dotagents.h (the .agents
+ * protocol layer). Zero dependencies: compiles to a single TU with no
+ * linked libraries (frontmatter.h provides everything it needs). */
+#include "dotagents.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+static char *tmpdir(const char *tag) {
+ char path[256];
+ snprintf(path, sizeof path, "/tmp/llshc-da-%s-%d", tag, (int)getpid());
+ /* rm -rf */
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "rm -rf '%s'", path);
+ if (system(cmd) != 0) {}
+ /* mkdir -p */
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path);
+ if (system(cmd) != 0)
+ exit(1);
+ return fm_strdup(path);
+}
+
+static void wfile_bin(const char *dir, const char *rel, const char *content) {
+ char path[512];
+ snprintf(path, sizeof path, "%s/%s", dir, rel);
+ char *slash = strrchr(path, '/');
+ if (slash) {
+ char dirpart[512];
+ size_t n = (size_t)(slash - path);
+ memcpy(dirpart, path, n);
+ dirpart[n] = '\0';
+ char cmd[600];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart);
+ if (system(cmd) != 0)
+ exit(1);
+ }
+ FILE *f = fopen(path, "w");
+ if (!f)
+ exit(1);
+ fputs(content, f);
+ fclose(f);
+}
+
+static int count_warns(const dotagents *d, const char *needle) {
+ int n = 0;
+ for (size_t i = 0; i < d->warnings.len; i++)
+ if (strstr(d->warnings.items[i], needle))
+ n++;
+ return n;
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer tests */
+/* ------------------------------------------------------------------ */
+
+static void test_loads_global_only(void) {
+ char *ws = tmpdir("ws1");
+ char *global = tmpdir("g1");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: global skill\n---\ndo g1");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.found_count == 1, "one layer found");
+ CHECK(d.found_layers[0] == LAYER_GLOBAL, "layer is global");
+ CHECK(d.agents_md && strcmp(d.agents_md, "global guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 1, "one skill");
+ CHECK(strcmp(d.skills[0].id, "g1") == 0, "skill id");
+ CHECK(strcmp(d.skills[0].body, "do g1") == 0, "skill body");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_md_filenames_case_insensitive(void) {
+ char *ws = tmpdir("ws-cs");
+ wfile_bin(ws, "AGENTS.MD", "workspace guidelines");
+ wfile_bin(ws, "System-Prompt.md", "the system prompt");
+ wfile_bin(ws, "skills/UPPER/SKILL.md",
+ "---\nid: upper\nname: Upper\ndescription: caps skill\n---\ndo "
+ "upper");
+ wfile_bin(ws, "agents/REVIEWER/Agent.md",
+ "---\nid: reviewer\nname: Reviewer\n---\nYou review.");
+ wfile_bin(ws, "tasks/daily/Task.MD",
+ "---\nid: daily\nname: Daily\nenabled: true\n---\nRun daily.");
+ wfile_bin(ws, "memories/note.MD",
+ "---\nid: note\ntitle: Note\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "workspace found");
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "AGENTS.MD");
+ CHECK(d.system_prompt && strcmp(d.system_prompt, "the system prompt") == 0,
+ "System-Prompt.md");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "upper") == 0,
+ "SKILL.md");
+ CHECK(d.sub_agent_count == 1 &&
+ strcmp(d.sub_agents[0].id, "reviewer") == 0,
+ "Agent.md");
+ CHECK(d.task_count == 1 && strcmp(d.tasks[0].id, "daily") == 0,
+ "Task.MD");
+ CHECK(d.memory_count == 1 && strcmp(d.memories[0].id, "note") == 0,
+ "note.MD");
+ CHECK(d.warnings.len == 0, "no case warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_workspace_wins(void) {
+ char *ws = tmpdir("ws2");
+ char *global = tmpdir("g2");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/review/skill.md",
+ "---\nid: review\nname: Review G\ndescription: global\n---\n"
+ "global body");
+ wfile_bin(global, "memories/m.md",
+ "---\nid: m\ntitle: M Global\nimportance: low\ntags: a\n---\ng");
+ wfile_bin(ws, "agents.md", "workspace guidelines");
+ wfile_bin(ws, "skills/review/skill.md",
+ "---\nid: review\nname: Review W\ndescription: workspace\n---\n"
+ "workspace body");
+ wfile_bin(ws, "skills/extra/skill.md",
+ "---\nid: extra\nname: Extra\n---\nextra body");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "workspace agents.md wins");
+ const skill *review = dotagents_find_skill(&d, "review");
+ CHECK(review && review->layer == LAYER_WORKSPACE, "review layer");
+ CHECK(review && strcmp(review->body, "workspace body") == 0,
+ "review body");
+ CHECK(d.skill_count == 2, "review + extra");
+ CHECK(d.memory_count == 1, "only global memory");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_json_layers_merge_by_key(void) {
+ char *ws = tmpdir("ws3");
+ char *global = tmpdir("g3");
+ wfile_bin(global, "models.json",
+ "{\"default\":\"ga\",\"presets\":{\"ga\":{\"model\":\"ga-"
+ "model\"},\"shared\":{\"model\":\"s\"}}}");
+ wfile_bin(global, "mcp.json",
+ "{\"mcpServers\":{\"fs\":{\"command\":\"npx\"},"
+ "\"git\":{\"url\":\"g\"}}}");
+ wfile_bin(ws, "models.json",
+ "{\"default\":\"wb\",\"presets\":{\"wb\":{\"provider\":\""
+ "ollama\"},\"shared\":{\"model\":\"ws\"}}}");
+ wfile_bin(ws, "mcp.json", "{\"mcpServers\":{\"git\":{\"url\":\"w\"}}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.has_models, "models present");
+ CHECK(d.models.default_name &&
+ strcmp(d.models.default_name, "wb") == 0,
+ "default=wb");
+ CHECK(d.models.preset_count == 3, "3 presets");
+ const model_preset *ga = models_file_get(&d.models, "ga");
+ CHECK(ga && ga->model && strcmp(ga->model, "ga-model") == 0, "ga.model");
+ const model_preset *sh = models_file_get(&d.models, "shared");
+ CHECK(sh && sh->model && strcmp(sh->model, "ws") == 0, "shared.model");
+ CHECK(d.mcp_count == 2, "2 mcp servers");
+ const char *git = dotagents_mcp_config(&d, "git");
+ CHECK(git && strstr(git, "\"url\":\"w\""), "git.url=w");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_malformed_files_warn_and_skip(void) {
+ char *ws = tmpdir("ws4");
+ wfile_bin(ws, "models.json", "{not json");
+ wfile_bin(ws, "mcp.json", "{\"nope\":true}");
+ wfile_bin(ws, "skills/broken/skill.md", "no frontmatter");
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s/skills/empty'", ws);
+ if (system(cmd) != 0)
+ exit(1);
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(!d.has_models, "no models");
+ CHECK(d.mcp_count == 0, "no mcp");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "broken") == 0,
+ "broken skill kept");
+ CHECK(d.skills[0].body &&
+ strstr(d.skills[0].body, "no frontmatter"),
+ "broken skill body");
+ CHECK(count_warns(&d, "models.json") > 0, "models.json warning");
+ CHECK(count_warns(&d, "mcp.json") > 0, "mcp.json warning");
+ CHECK(count_warns(&d, "skills/empty") > 0, "skills/empty warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_missing_layers_empty(void) {
+ char *ws = tmpdir("ws5");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 0, "no layers");
+ CHECK(d.skill_count == 0, "no skills");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_tasks_and_sub_agents(void) {
+ char *ws = tmpdir("ws6");
+ wfile_bin(ws, "tasks/daily/task.md",
+ "---\nkind: task\nid: daily\nname: Daily Review\nintervalMinutes"
+ ": 60\nenabled: true\nrunOnStartup: true\n---\nReview open "
+ "PRs.");
+ wfile_bin(ws, "agents/reviewer/agent.md",
+ "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n"
+ "role: delegation-target\nenabled: true\n---\nYou are a "
+ "reviewer.");
+ wfile_bin(ws, "agents/reviewer/config.json",
+ "{\"modelConfig\":{\"mcpToolsOpenaiModel\":\"gpt-4o\"}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].has_interval && d.tasks[0].interval_minutes == 60,
+ "interval");
+ CHECK(d.tasks[0].run_on_startup, "run_on_startup");
+ CHECK(d.tasks[0].enabled, "enabled");
+ CHECK(strcmp(d.tasks[0].body, "Review open PRs.") == 0, "task body");
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(strcmp(d.sub_agents[0].role, "delegation-target") == 0, "role");
+ CHECK(strcmp(d.sub_agents[0].body, "You are a reviewer.") == 0,
+ "sub agent body");
+ CHECK(d.sub_agents[0].config != NULL, "config present");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_malformed_sub_agent_config(void) {
+ char *ws = tmpdir("ws9");
+ wfile_bin(ws, "agents/broken/agent.md",
+ "---\nid: broken\nname: Broken\ndescription: has a bad config\n"
+ "---\nYou are broken.");
+ wfile_bin(ws, "agents/broken/config.json", "{not json");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(d.sub_agents[0].config == NULL, "config dropped");
+ CHECK(count_warns(&d, "agents/broken/config.json") > 0,
+ "config warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_memories_rank(void) {
+ memory low, high;
+ memset(&low, 0, sizeof low);
+ low.importance = fm_strdup("low");
+ memset(&high, 0, sizeof high);
+ high.importance = fm_strdup("high");
+ CHECK(memory_importance_rank(&high) < memory_importance_rank(&low),
+ "high ranks before low");
+ free(low.importance);
+ free(high.importance);
+}
+
+static void test_project_sections(void) {
+ char *ws = tmpdir("ws7");
+ wfile_bin(ws, "agents.md", "# Guidelines\n- be nice");
+ wfile_bin(ws, "skills/cr/skill.md",
+ "---\nid: cr\nname: CR\ndescription: code review\n---\nreview "
+ "steps");
+ wfile_bin(ws, "memories/p.md",
+ "---\nid: p\ntitle: P\nimportance: high\ntags: x\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- be nice"), "guidelines");
+ CHECK(strstr(sections, "- cr: code review"), "skill line");
+ CHECK(strstr(sections, "use_skill"), "use_skill mention");
+ CHECK(strstr(sections, "search_memories"), "search_memories mention");
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "BASE") && strstr(sections, "be nice") &&
+ memcmp(pre, "BASE", 4) == 0,
+ "preamble = base + sections");
+ free(sections);
+ free(pre);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_skill_index_cap(void) {
+ char *ws = tmpdir("ws10");
+ const char *ids[5] = {"a", "b", "c", "d", "e"};
+ for (int i = 0; i < 5; i++) {
+ char rel[64];
+ char content[128];
+ snprintf(rel, sizeof rel, "skills/%s/skill.md", ids[i]);
+ snprintf(content, sizeof content,
+ "---\nid: %s\nname: %s\ndescription: skill %d\n---\nbody",
+ ids[i], ids[i], i);
+ wfile_bin(ws, rel, content);
+ }
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- a: skill 0"), "a listed");
+ CHECK(strstr(sections, "- b: skill 1"), "b listed");
+ CHECK(!strstr(sections, "- c: skill 2"), "c not listed");
+ CHECK(strstr(sections, "and 3 more"), "…and 3 more");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_disabled_skills_excluded(void) {
+ char *ws = tmpdir("ws8");
+ wfile_bin(ws, "skills/off/skill.md",
+ "---\nid: off\ndescription: hidden\nenabled: false\n---\nx");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.skill_count == 1, "skill kept");
+ CHECK(!d.skills[0].enabled, "disabled");
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(!strstr(sections, "off"), "not in sections");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* ------------------------------------------------------------------ */
+/* Spec-gap conformance */
+/* ------------------------------------------------------------------ */
+
+static void test_task_profile_id(void) {
+ char *ws = tmpdir("ws-pid");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nkind: task\nid: nightly\nname: Nightly\nprofileId: "
+ "prof-9\nenabled: true\n---\nRun nightly.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "prof-9") == 0,
+ "profile_id parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_task_profile_id_workspace_override(void) {
+ char *ws = tmpdir("ws-pid2");
+ char *global = tmpdir("g-pid");
+ wfile_bin(global, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: g-prof\n---\ng");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: w-prof\n---\nw");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "w-prof") == 0,
+ "workspace profileId wins");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_agent_connection_type(void) {
+ char *ws = tmpdir("ws-ct");
+ wfile_bin(ws, "agents/remote/agent.md",
+ "---\nid: remote\nname: Remote\nrole: delegation-target\n"
+ "connection-type: internal\nenabled: true\n---\nYou are "
+ "remote.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one agent");
+ CHECK(d.sub_agents[0].connection_type &&
+ strcmp(d.sub_agents[0].connection_type, "internal") == 0,
+ "connection-type parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_settings_layers_merge(void) {
+ char *ws = tmpdir("ws-set");
+ char *global = tmpdir("g-set");
+ wfile_bin(global, "speakmcp-settings.json",
+ "{\"theme\":\"dark\",\"telemetry\":true,\"shared\":\"g\"}");
+ wfile_bin(ws, "speakmcp-settings.json",
+ "{\"shared\":\"w\",\"volume\":2}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.settings_json != NULL, "settings present");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"shared\":\"w\""),
+ "shared=w");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"theme\":\"dark\""),
+ "theme kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"volume\":2"),
+ "volume kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"telemetry\":true"),
+ "telemetry kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_invalid_warns(void) {
+ char *ws = tmpdir("ws-setbad");
+ wfile_bin(ws, "speakmcp-settings.json", "{nope");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.settings_json == NULL, "no settings");
+ CHECK(count_warns(&d, "speakmcp-settings.json") > 0, "settings warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_ui_merge(void) {
+ char *ws = tmpdir("ws-lay");
+ char *global = tmpdir("g-lay");
+ wfile_bin(global, "layouts/ui.json", "{\"panes\":2,\"theme\":\"dark\"}");
+ wfile_bin(ws, "layouts/ui.json", "{\"theme\":\"light\"}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"theme\":\"light\""),
+ "theme=w");
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"panes\":2"),
+ "panes kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-setonly");
+ wfile_bin(ws, "speakmcp-settings.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.settings_json != NULL, "settings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-layonly");
+ wfile_bin(ws, "layouts/ui.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.layouts_json != NULL, "layouts");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_backups_ignored(void) {
+ char *ws = tmpdir("ws-bk");
+ wfile_bin(ws, "agents.md", "guidelines");
+ wfile_bin(ws, ".backups/skills/ghost/skill.md",
+ "---\nid: ghost\nname: Ghost\n---\nbody");
+ wfile_bin(ws, ".backups/memories/ghost.md",
+ "---\nid: ghost\ntitle: G\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.agents_md && strcmp(d.agents_md, "guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 0, "no ghost skill");
+ CHECK(d.memory_count == 0, "no ghost memory");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_unknown_frontmatter_keys_accepted(void) {
+ char *ws = tmpdir("ws-unk");
+ wfile_bin(ws, "agents/x/agent.md",
+ "---\nkind: agent\nid: x\nname: X\nmetadata: stuff\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "agent kept");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+int main(void) {
+ test_loads_global_only();
+ test_md_filenames_case_insensitive();
+ test_workspace_wins();
+ test_json_layers_merge_by_key();
+ test_malformed_files_warn_and_skip();
+ test_missing_layers_empty();
+ test_tasks_and_sub_agents();
+ test_malformed_sub_agent_config();
+ test_memories_rank();
+ test_project_sections();
+ test_skill_index_cap();
+ test_disabled_skills_excluded();
+ test_task_profile_id();
+ test_task_profile_id_workspace_override();
+ test_agent_connection_type();
+ test_settings_layers_merge();
+ test_settings_invalid_warns();
+ test_layouts_ui_merge();
+ test_settings_only_layer_discovered();
+ test_layouts_only_layer_discovered();
+ test_backups_ignored();
+ test_unknown_frontmatter_keys_accepted();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_escape.c b/tests/test_escape.c
new file mode 100644
index 0000000..e749443
--- /dev/null
+++ b/tests/test_escape.c
@@ -0,0 +1,113 @@
+// Tests for escape.h - ANSI escape code macros and functions
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define ESCAPE_IMPLEMENTATION
+#include "../include/escape.h"
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_escape.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_macros() {
+ ASSERT(ESC_CURSOR_UP(5)[0] == '\x1B', "cursor up starts with ESC");
+ ASSERT(ESC_CURSOR_DOWN(3)[0] == '\x1B', "cursor down starts with ESC");
+ ASSERT(ESC_CURSOR_LEFT(2)[0] == '\x1B', "cursor left starts with ESC");
+ ASSERT(ESC_CURSOR_RIGHT(2)[0] == '\x1B', "cursor right starts with ESC");
+ ASSERT(ESC_CLEAR_SCREEN[0] == '\x1B', "clear screen starts with ESC");
+ ASSERT(ESC_CLEAR_LINE[0] == '\x1B', "clear line starts with ESC");
+ ASSERT(ESC_RESET[0] == '\x1B', "reset starts with ESC");
+ ASSERT(ESC_BOLD[0] == '\x1B', "bold starts with ESC");
+ ASSERT(ESC_UNDERLINE[0] == '\x1B', "underline starts with ESC");
+ ASSERT(ESC_BLINK[0] == '\x1B', "blink starts with ESC");
+ ASSERT(ESC_REVERSE[0] == '\x1B', "reverse starts with ESC");
+ ASSERT(ESC_CONCEAL[0] == '\x1B', "conceal starts with ESC");
+ ASSERT(ESC_REVEAL[0] == '\x1B', "reveal starts with ESC");
+ ASSERT(ESC_STRIKETHROUGH[0] == '\x1B', "strikethrough starts with ESC");
+ ASSERT(ESC_OVERLINE[0] == '\x1B', "overline starts with ESC");
+}
+
+void test_color_macros() {
+ ASSERT(ESC_COLOR_FG(1)[0] == '\x1B', "fg color starts with ESC");
+ ASSERT(ESC_COLOR_BG(4)[0] == '\x1B', "bg color starts with ESC");
+ ASSERT(ESC_COLOR_FG_BRIGHT(3)[0] == '\x1B', "fg bright starts with ESC");
+ ASSERT(ESC_COLOR_BG_BRIGHT(5)[0] == '\x1B', "bg bright starts with ESC");
+ ASSERT(ESC_COLOR_FG_RGB(255, 128, 0)[0] == '\x1B', "fg rgb starts with ESC");
+ ASSERT(ESC_COLOR_BG_RGB(0, 128, 255)[0] == '\x1B', "bg rgb starts with ESC");
+ ASSERT(ESC_COLOR_FG_256(196)[0] == '\x1B', "fg 256 starts with ESC");
+ ASSERT(ESC_COLOR_BG_256(21)[0] == '\x1B', "bg 256 starts with ESC");
+}
+
+void test_cursor_position_macro() {
+ ASSERT(ESC_CURSOR_POSITION(10, 20)[0] == '\x1B', "cursor position starts with ESC");
+}
+
+void test_scroll_macros() {
+ ASSERT(ESC_SCROLL_UP(3)[0] == '\x1B', "scroll up starts with ESC");
+ ASSERT(ESC_SCROLL_DOWN(2)[0] == '\x1B', "scroll down starts with ESC");
+}
+
+void test_clear_macros() {
+ ASSERT(ESC_CLEAR_SCREEN_START[0] == '\x1B', "clear screen start");
+ ASSERT(ESC_CLEAR_SCREEN_END[0] == '\x1B', "clear screen end");
+ ASSERT(ESC_CLEAR_LINE_START[0] == '\x1B', "clear line start");
+ ASSERT(ESC_CLEAR_LINE_END[0] == '\x1B', "clear line end");
+ ASSERT(ESC_CURSOR_LINE_NEXT(2)[0] == '\x1B', "cursor line next");
+}
+
+void test_csi() {
+ ASSERT(CSI[0] == '\x1B', "CSI starts with ESC");
+ ASSERT(CSI[1] == '[', "CSI second char is [");
+}
+
+void test_fvariants() {
+ esc_fcursor_up(stdout, 3);
+
+ esc_fclear_line(stdout);
+
+ esc_fcolor_fg_256(stdout, 196);
+
+ esc_fcursor_position(stdout, 10, 20);
+
+ esc_fscroll_up(stdout, 2);
+
+ esc_fscroll_down(stdout, 1);
+
+ esc_fclear_screen(stdout);
+
+ esc_fcolor_fg_rgb(stdout, 255, 128, 0);
+
+ esc_fcolor_bg_rgb(stdout, 0, 128, 255);
+
+ esc_fcursor_down(stdout, 5);
+ esc_fcursor_left(stdout, 2);
+ esc_fcursor_right(stdout, 2);
+ esc_fcursor_line_next(stdout, 1);
+ esc_fclear_screen_start(stdout);
+ esc_fclear_screen_end(stdout);
+ esc_fclear_line_start(stdout);
+ esc_fclear_line_end(stdout);
+ esc_fcolor_bg_256(stdout, 21);
+}
+
+int main() {
+ test_macros();
+ test_color_macros();
+ test_cursor_position_macro();
+ test_scroll_macros();
+ test_clear_macros();
+ test_csi();
+ test_fvariants();
+
+ printf("test_escape: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_flat.c b/tests/test_flat.c
new file mode 100644
index 0000000..2710ce5
--- /dev/null
+++ b/tests/test_flat.c
@@ -0,0 +1,132 @@
+// Tests for flat.h - directory traversal utilities
+#define FLAT_IMPLEMENTATION
+#include "../include/flat.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_flat.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_path() {
+ char *p = flat_path(3, "a", "b", "c");
+ ASSERT(p != NULL, "path returns non-NULL");
+ ASSERT(strcmp(p, "a/b/c") == 0, "path a/b/c");
+ free(p);
+
+ p = flat_path(2, "home", "user");
+ ASSERT(strcmp(p, "home/user") == 0, "path home/user");
+ free(p);
+
+ p = flat_path(1, "single");
+ ASSERT(strcmp(p, "single") == 0, "path single component");
+ free(p);
+}
+
+void test_path_zero() {
+ char *p = flat_path(0, NULL);
+ ASSERT(p == NULL, "zero components returns NULL");
+}
+
+void test_entry_count() {
+ ASSERT(mkdir("/tmp/test_flat_dir", 0777) == 0 || errno == EEXIST, "mkdir test_flat_dir");
+
+ FILE *f1 = fopen("/tmp/test_flat_dir/file1.txt", "w");
+ ASSERT(f1 != NULL, "fopen file1.txt");
+ if (!f1) { rmdir("/tmp/test_flat_dir"); return; }
+ fclose(f1);
+ FILE *f2 = fopen("/tmp/test_flat_dir/file2.txt", "w");
+ ASSERT(f2 != NULL, "fopen file2.txt");
+ if (!f2) { unlink("/tmp/test_flat_dir/file1.txt"); rmdir("/tmp/test_flat_dir"); return; }
+ fclose(f2);
+
+ int64_t count = flat_directory_entry_count("/tmp/test_flat_dir");
+ ASSERT(count >= 2, "entry count >= 2");
+
+ unlink("/tmp/test_flat_dir/file1.txt");
+ unlink("/tmp/test_flat_dir/file2.txt");
+ rmdir("/tmp/test_flat_dir");
+}
+
+void test_entries() {
+ ASSERT(mkdir("/tmp/test_flat_dir2", 0777) == 0 || errno == EEXIST, "mkdir test_flat_dir2");
+
+ FILE *f1 = fopen("/tmp/test_flat_dir2/alpha.txt", "w");
+ ASSERT(f1 != NULL, "fopen alpha.txt");
+ if (!f1) { rmdir("/tmp/test_flat_dir2"); return; }
+ fclose(f1);
+ FILE *f2 = fopen("/tmp/test_flat_dir2/beta.txt", "w");
+ ASSERT(f2 != NULL, "fopen beta.txt");
+ if (!f2) { unlink("/tmp/test_flat_dir2/alpha.txt"); rmdir("/tmp/test_flat_dir2"); return; }
+ fclose(f2);
+
+ char **entries = flat_directory_entries("/tmp/test_flat_dir2");
+ ASSERT(entries != NULL, "entries returns non-NULL");
+ if (!entries) {
+ unlink("/tmp/test_flat_dir2/alpha.txt");
+ unlink("/tmp/test_flat_dir2/beta.txt");
+ rmdir("/tmp/test_flat_dir2");
+ return;
+ }
+
+ int found_alpha = 0, found_beta = 0;
+ for (int64_t i = 0; entries[i]; i++) {
+ if (strcmp(entries[i], "alpha.txt") == 0) found_alpha = 1;
+ if (strcmp(entries[i], "beta.txt") == 0) found_beta = 1;
+ }
+
+ ASSERT(found_alpha, "found alpha.txt");
+ ASSERT(found_beta, "found beta.txt");
+
+ flat_free_entries(entries);
+
+ unlink("/tmp/test_flat_dir2/alpha.txt");
+ unlink("/tmp/test_flat_dir2/beta.txt");
+ rmdir("/tmp/test_flat_dir2");
+}
+
+void test_entries_invalid() {
+ char **entries = flat_directory_entries("/nonexistent/path");
+ ASSERT(entries == NULL, "invalid path returns NULL");
+}
+
+void test_free_entries_null() {
+ flat_free_entries(NULL);
+}
+
+void test_entry_count_invalid() {
+ int64_t count = flat_directory_entry_count("/nonexistent/path");
+ ASSERT(count == 0, "invalid path returns 0 count");
+}
+
+void test_path_with_empty() {
+ char *p = flat_path(3, "a", "", "c");
+ ASSERT(p != NULL, "path with empty returns non-NULL");
+ ASSERT(strcmp(p, "a//c") == 0, "path a//c with empty");
+ free(p);
+}
+
+int main() {
+ test_path();
+ test_path_zero();
+ test_entry_count();
+ test_entries();
+ test_entries_invalid();
+ test_free_entries_null();
+ test_entry_count_invalid();
+ test_path_with_empty();
+
+ printf("test_flat: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_frontmatter.c b/tests/test_frontmatter.c
new file mode 100644
index 0000000..af4c2fc
--- /dev/null
+++ b/tests/test_frontmatter.c
@@ -0,0 +1,236 @@
+/* test_frontmatter.c — standalone test suite for frontmatter.h.
+ * Zero dependencies: compiles to a single TU with no linked libraries. */
+#include "frontmatter.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+static void test_frontmatter_scalars_lists_quotes(void) {
+ doc d;
+ parse_frontmatter(
+ "---\nid: a-1\nname: \"A: One\"\nenabled: true\nn: 42\ntags: db, "
+ "arch\ntags2: [\"x\", \"y\"]\n---\nbody line",
+ &d);
+ CHECK(strcmp(d.body, "body line") == 0, "body");
+ const fm_value *v = doc_get(&d, "id");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "a-1") == 0, "id");
+ v = doc_get(&d, "name");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "A: One") == 0, "name");
+ v = doc_get(&d, "enabled");
+ CHECK(v && v->kind == FM_BOOL && v->b, "enabled");
+ v = doc_get(&d, "n");
+ CHECK(v && v->kind == FM_INT && v->i == 42, "n");
+ v = doc_get(&d, "tags");
+ CHECK(v, "tags present");
+ fm_strlist items;
+ fm_list_items(v, &items);
+ CHECK(items.len == 2 && strcmp(items.items[0], "db") == 0 &&
+ strcmp(items.items[1], "arch") == 0,
+ "tags csv");
+ fm_sl_free(&items);
+ v = doc_get(&d, "tags2");
+ CHECK(v && v->kind == FM_LIST && v->item_count == 2 &&
+ strcmp(v->items[0], "x") == 0 && strcmp(v->items[1], "y") == 0,
+ "tags2 array");
+ doc_free(&d);
+}
+
+static void test_frontmatter_missing_or_unterminated(void) {
+ doc d;
+ parse_frontmatter("just a body\n", &d);
+ CHECK(d.count == 0, "no meta");
+ CHECK(strcmp(d.body, "just a body") == 0, "body trimmed");
+ doc_free(&d);
+
+ parse_frontmatter("---\nid: x\nno closing fence\n", &d);
+ CHECK(d.count == 0, "unterminated: no meta");
+ CHECK(strcmp(d.body, "---\nid: x\nno closing fence") == 0,
+ "unterminated: whole text is body");
+ doc_free(&d);
+}
+
+static void test_frontmatter_ignores_blank_comment_colonless(void) {
+ doc d;
+ parse_frontmatter("---\n# comment\n\nnotakey\nk: v\n---\n", &d);
+ CHECK(d.count == 1, "one meta entry");
+ const fm_value *v = doc_get(&d, "k");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "v") == 0, "k=v");
+ doc_free(&d);
+}
+
+static void test_fm_list_items_variants(void) {
+ doc d;
+ parse_frontmatter(
+ "---\ncsv: a, b, c\njson: [\"x\", \"y\"]\nsingle: only\nempty:\n"
+ "nums: [1, 2]\n---\nbody",
+ &d);
+ fm_strlist out;
+ fm_list_items(doc_get(&d, "csv"), &out);
+ CHECK(out.len == 3 && strcmp(out.items[0], "a") == 0 &&
+ strcmp(out.items[2], "c") == 0,
+ "csv");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "json"), &out);
+ CHECK(out.len == 2 && strcmp(out.items[0], "x") == 0, "json");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "single"), &out);
+ CHECK(out.len == 1 && strcmp(out.items[0], "only") == 0, "single");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "empty"), &out);
+ CHECK(out.len == 0, "empty list");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "nums"), &out);
+ CHECK(out.len == 2 && strcmp(out.items[0], "1") == 0, "non-string items");
+ fm_sl_free(&out);
+ fm_list_items(NULL, &out);
+ CHECK(out.len == 0, "null value");
+ fm_sl_free(&out);
+ doc_free(&d);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON */
+/* ------------------------------------------------------------------ */
+
+static void test_json_roundtrip(void) {
+ const char *src =
+ "{\"a\":1,\"b\":\"x\",\"c\":[true,null,1.5],\"d\":{\"e\":[1,2,"
+ "\"z\"]}}";
+ fm_json *v = fm_json_parse(src);
+ CHECK(v != NULL, "parse");
+ CHECK(v && v->type == FMJ_OBJECT, "object");
+ CHECK(v && v->count == 4, "4 keys");
+ const fm_json *a = fm_json_get(v, "a");
+ CHECK(a && a->type == FMJ_NUMBER && a->is_int && a->i == 1, "a=1");
+ const fm_json *b = fm_json_get(v, "b");
+ CHECK(b && b->type == FMJ_STRING && strcmp(b->str, "x") == 0, "b=x");
+ const fm_json *c = fm_json_get(v, "c");
+ CHECK(c && c->type == FMJ_ARRAY && c->count == 3, "c array");
+ CHECK(c && c->items[0]->type == FMJ_BOOL && c->items[0]->b, "c[0]=true");
+ CHECK(c && c->items[1]->type == FMJ_NULL, "c[1]=null");
+ CHECK(c && c->items[2]->type == FMJ_NUMBER && c->items[2]->num == 1.5,
+ "c[2]=1.5");
+ const fm_json *d = fm_json_get(v, "d");
+ CHECK(d && d->type == FMJ_OBJECT, "d object");
+ const fm_json *e = d ? fm_json_get(d, "e") : NULL;
+ CHECK(e && e->type == FMJ_ARRAY && e->count == 3, "d.e array");
+ CHECK(e && e->items[1]->is_int && e->items[1]->i == 2, "d.e[1]=2");
+ char *out = fm_json_serialize(v);
+ CHECK(out && strcmp(out, src) == 0, "roundtrip");
+ free(out);
+ fm_json_free(v);
+}
+
+static void test_json_escapes(void) {
+ fm_json *v = fm_json_parse("\"a\\\"b\\\\c\\nd\\u0041\"");
+ CHECK(v && v->type == FMJ_STRING, "parse");
+ CHECK(v && strcmp(v->str, "a\"b\\c\ndA") == 0, "unescaped value");
+ char *s = fm_json_serialize(v);
+ CHECK(s && strcmp(s, "\"a\\\"b\\\\c\\ndA\"") == 0, "escaped output");
+ free(s);
+ fm_json_free(v);
+
+ fm_json *u = fm_json_parse("\"\\u4e2d\"");
+ CHECK(u && u->type == FMJ_STRING, "unicode parse");
+ CHECK(u && strcmp(u->str, "\xE4\xB8\xAD") == 0, "utf-8 out");
+ fm_json_free(u);
+}
+
+static void test_json_invalid(void) {
+ CHECK(fm_json_parse("{") == NULL, "open brace");
+ CHECK(fm_json_parse("[1,") == NULL, "dangling comma");
+ CHECK(fm_json_parse("{bad:1}") == NULL, "bare key");
+ CHECK(fm_json_parse("{\"a\":1\"b\":2}") == NULL, "missing comma");
+ CHECK(fm_json_parse("{}x") == NULL, "trailing garbage");
+ CHECK(fm_json_parse("") == NULL, "empty");
+ CHECK(fm_json_parse("01") == NULL, "leading zero");
+ CHECK(fm_json_parse("1.") == NULL, "dangling dot");
+ CHECK(fm_json_parse("-") == NULL, "bare minus");
+ CHECK(fm_json_parse("tru") == NULL, "bare tru");
+ CHECK(fm_json_parse("\"unterminated") == NULL, "unterminated string");
+ CHECK(fm_json_parse("[1 2]") == NULL, "missing separator");
+ fm_json *f1 = fm_json_parse("1.5");
+ CHECK(f1 != NULL, "float ok");
+ fm_json_free(f1);
+ fm_json *f2 = fm_json_parse("1e3");
+ CHECK(f2 != NULL, "exponent ok");
+ fm_json_free(f2);
+ fm_json *f3 = fm_json_parse("null");
+ CHECK(f3 != NULL, "null ok");
+ fm_json_free(f3);
+ fm_json *f4 = fm_json_parse("[]");
+ CHECK(f4 != NULL, "empty array ok");
+ fm_json_free(f4);
+ fm_json *f5 = fm_json_parse("{}");
+ CHECK(f5 != NULL, "empty object ok");
+ fm_json_free(f5);
+}
+
+static void test_json_merge(void) {
+ fm_json *base = fm_json_parse("{\"a\":1,\"b\":{\"x\":1}}");
+ fm_json *over = fm_json_parse("{\"b\":{\"y\":2},\"c\":3}");
+ fm_json *m = fm_json_merge_owned(base, over);
+ char *s = fm_json_serialize(m);
+ CHECK(s && strcmp(s, "{\"b\":{\"y\":2},\"c\":3,\"a\":1}") == 0,
+ "merged content");
+ free(s);
+ fm_json_free(m);
+
+ fm_json *o2 = fm_json_parse("{\"z\":9}");
+ fm_json *m2 = fm_json_merge_owned(NULL, o2);
+ char *s2 = fm_json_serialize(m2);
+ CHECK(s2 && strcmp(s2, "{\"z\":9}") == 0, "over only");
+ free(s2);
+ fm_json_free(m2);
+
+ fm_json *b2 = fm_json_parse("{\"q\":1}");
+ fm_json *m3 = fm_json_merge_owned(b2, NULL);
+ char *s3 = fm_json_serialize(m3);
+ CHECK(s3 && strcmp(s3, "{\"q\":1}") == 0, "base only");
+ free(s3);
+ fm_json_free(m3);
+
+ fm_json *n1 = fm_json_parse("[1]");
+ fm_json *n2 = fm_json_parse("\"x\"");
+ fm_json *m4 = fm_json_merge_owned(n1, n2);
+ char *s4 = fm_json_serialize(m4);
+ CHECK(s4 && strcmp(s4, "{}") == 0, "non-object inputs dropped");
+ free(s4);
+ fm_json_free(m4);
+
+ fm_json *m5 = fm_json_merge_owned(NULL, NULL);
+ char *s5 = fm_json_serialize(m5);
+ CHECK(s5 && strcmp(s5, "{}") == 0, "both null");
+ free(s5);
+ fm_json_free(m5);
+}
+
+int main(void) {
+ test_frontmatter_scalars_lists_quotes();
+ test_frontmatter_missing_or_unterminated();
+ test_frontmatter_ignores_blank_comment_colonless();
+ test_fm_list_items_variants();
+ test_json_roundtrip();
+ test_json_escapes();
+ test_json_invalid();
+ test_json_merge();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_genetic.c b/tests/test_genetic.c
new file mode 100644
index 0000000..e7bb55b
--- /dev/null
+++ b/tests/test_genetic.c
@@ -0,0 +1,158 @@
+// Tests for genetic.h - genetic algorithm generation step
+typedef char *ga_individual_t;
+#define GENETIC_IMPLEMENTATION
+#include "../include/genetic.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <float.h>
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_genetic.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+#define TARGET "HELLO"
+#define POP_N 20
+
+static void crossover_fn(ga_individual_t *child, ga_individual_t p1, ga_individual_t p2) {
+ if (!child || !p1 || !p2)
+ return;
+ uint32_t l1 = strlen(p1);
+ uint32_t l2 = strlen(p2);
+ if (l1 == 0 || l2 == 0)
+ return;
+ uint32_t c1 = rand() % l1;
+ uint32_t c2 = rand() % l2;
+ uint32_t len = c1 + (l2 - c2);
+ char *s = malloc(len + 1);
+ if (!s) {
+ free(*child);
+ *child = NULL;
+ return;
+ }
+ memcpy(s, p1, c1);
+ memcpy(s + c1, p2 + c2, l2 - c2);
+ s[len] = '\0';
+ free(*child);
+ *child = s;
+}
+
+static void mutation_fn(ga_individual_t *ind) {
+ if (!ind || !*ind)
+ return;
+ uint32_t l = strlen(*ind);
+ if (l == 0)
+ return;
+ (*ind)[rand() % l] = (char)(rand() % 26 + 'A');
+}
+
+static double fitness_fn(ga_individual_t ind, void const *data) {
+ if (!ind)
+ return DBL_MAX;
+ char const *target = (char const *)data;
+ double error = 0;
+ while (*ind && *target) {
+ error += abs((int)(*target++ - *ind++));
+ }
+ while (*ind) error += abs((int)*ind++);
+ while (*target) error += abs((int)*target++);
+ return error;
+}
+
+static void free_population(char *population[], int n) {
+ for (int i = 0; i < n; i++)
+ free(population[i]);
+}
+
+void test_generation() {
+ srand(42);
+ char *population[POP_N];
+
+ for (int i = 0; i < POP_N; i++) {
+ uint32_t len = 4 + rand() % 3;
+ char *s = malloc(len + 1);
+ if (!s) { free_population(population, i); exit(1); }
+ for (uint32_t j = 0; j < len; j++) {
+ s[j] = (char)(rand() % 26 + 'A');
+ }
+ s[len] = '\0';
+ population[i] = s;
+ }
+
+ double error = ga_generation(population, POP_N, 0.5, 1.0, fitness_fn, TARGET,
+ crossover_fn, mutation_fn);
+
+ ASSERT(error >= 0, "error non-negative");
+
+ for (int i = POP_N - 1; i >= 0; i--) {
+ free(population[i]);
+ }
+}
+
+void test_convergence() {
+ srand(42);
+ char *population[POP_N];
+
+ for (int i = 0; i < POP_N; i++) {
+ uint32_t len = strlen(TARGET);
+ char *s = malloc(len + 1);
+ if (!s) { free_population(population, i); exit(1); }
+ for (uint32_t j = 0; j < len; j++) {
+ s[j] = (char)(rand() % 26 + 'A');
+ }
+ s[len] = '\0';
+ population[i] = s;
+ }
+
+ double error = 1;
+ int generations = 0;
+ while (error > 0 && generations < 500) {
+ error = ga_generation(population, POP_N, 0.5, 1.0, fitness_fn, TARGET,
+ crossover_fn, mutation_fn);
+ generations++;
+ }
+
+ ASSERT(generations < 500, "converges within 500 generations");
+
+ for (int i = 0; i < POP_N; i++) {
+ free(population[i]);
+ }
+}
+
+void test_selection_rate() {
+ srand(42);
+ char *population[POP_N];
+
+ for (int i = 0; i < POP_N; i++) {
+ char *s = malloc(6);
+ if (!s) { free_population(population, i); exit(1); }
+ strcpy(s, "HELLO");
+ population[i] = s;
+ }
+
+ double error = ga_generation(population, POP_N, 0.5, 0.1, fitness_fn, TARGET,
+ crossover_fn, mutation_fn);
+ ASSERT(error >= 0, "low mutation rate returns non-negative error");
+ ASSERT(error == 0, "all-population-matching-target has zero error");
+
+ for (int i = 0; i < POP_N; i++) {
+ free(population[i]);
+ }
+}
+
+int main() {
+ test_generation();
+ test_convergence();
+ test_selection_rate();
+
+ printf("test_genetic: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_interpolate.c b/tests/test_interpolate.c
new file mode 100644
index 0000000..5e7166b
--- /dev/null
+++ b/tests/test_interpolate.c
@@ -0,0 +1,112 @@
+// Tests for interpolate.h - interpolation and easing macros
+#include <stdio.h>
+#include <math.h>
+
+#include "../include/interpolate.h"
+
+#define EPS 1e-6
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_interpolate.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_FLOAT_EQ(a, b, msg) \
+ do { \
+ if (fabs((a) - (b)) > EPS) { \
+ fprintf(stderr, "FAIL: %s (got %f, expected %f) (test_interpolate.c:%d)\n", \
+ msg, (double)(a), (double)(b), __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_flip() {
+ ASSERT_FLOAT_EQ(flip(0.0), 1.0, "flip(0)");
+ ASSERT_FLOAT_EQ(flip(1.0), 0.0, "flip(1)");
+ ASSERT_FLOAT_EQ(flip(0.5), 0.5, "flip(0.5)");
+}
+
+void test_smooth_start() {
+ ASSERT_FLOAT_EQ(smooth_start_2(0.0), 0.0, "smooth_start_2(0)");
+ ASSERT_FLOAT_EQ(smooth_start_2(1.0), 1.0, "smooth_start_2(1)");
+ ASSERT_FLOAT_EQ(smooth_start_2(0.5), 0.25, "smooth_start_2(0.5)");
+
+ ASSERT_FLOAT_EQ(smooth_start_3(0.0), 0.0, "smooth_start_3(0)");
+ ASSERT_FLOAT_EQ(smooth_start_3(1.0), 1.0, "smooth_start_3(1)");
+}
+
+void test_smooth_stop() {
+ ASSERT_FLOAT_EQ(smooth_stop_2(0.0), 0.0, "smooth_stop_2(0)");
+ ASSERT_FLOAT_EQ(smooth_stop_2(1.0), 1.0, "smooth_stop_2(1)");
+}
+
+void test_mix() {
+ ASSERT_FLOAT_EQ(mix(0.0, 10.0, 0.0), 0.0, "mix t=0");
+ ASSERT_FLOAT_EQ(mix(0.0, 10.0, 1.0), 10.0, "mix t=1");
+ ASSERT_FLOAT_EQ(mix(0.0, 10.0, 0.5), 5.0, "mix t=0.5");
+}
+
+void test_crossfade() {
+ ASSERT_FLOAT_EQ(crossfade(0.0, 10.0, 0.0), 0.0, "crossfade t=0");
+ ASSERT_FLOAT_EQ(crossfade(0.0, 10.0, 1.0), 10.0, "crossfade t=1");
+ ASSERT_FLOAT_EQ(crossfade(5.0, 10.0, 0.5), 7.5, "crossfade mid");
+}
+
+void test_scale() {
+ ASSERT_FLOAT_EQ(scale(10.0, 0.5), 5.0, "scale 10*0.5");
+ ASSERT_FLOAT_EQ(scale(10.0, 1.0), 10.0, "scale 10*1");
+ ASSERT_FLOAT_EQ(scale(10.0, 0.0), 0.0, "scale 10*0");
+}
+
+void test_reverse_scale() {
+ ASSERT_FLOAT_EQ(reverse_scale(10.0, 0.0), 10.0, "reverse_scale t=0");
+ ASSERT_FLOAT_EQ(reverse_scale(10.0, 1.0), 0.0, "reverse_scale t=1");
+ ASSERT_FLOAT_EQ(reverse_scale(10.0, 0.5), 5.0, "reverse_scale t=0.5");
+}
+
+void test_arch() {
+ ASSERT_FLOAT_EQ(arch_2(0.0), 0.0, "arch_2(0)");
+ ASSERT_FLOAT_EQ(arch_2(1.0), 0.0, "arch_2(1)");
+ ASSERT_FLOAT_EQ(arch_2(0.5), 0.25, "arch_2(0.5)");
+}
+
+void test_smooth_step() {
+ ASSERT_FLOAT_EQ(smooth_step_3(0.0), 0.0, "smooth_step_3(0)");
+ ASSERT_FLOAT_EQ(smooth_step_3(1.0), 1.0, "smooth_step_3(1)");
+}
+
+void test_namespaced_macros() {
+ ASSERT_FLOAT_EQ(INTERP_FLIP(0.3), 0.7, "INTERP_FLIP");
+ ASSERT_FLOAT_EQ(INTERP_SCALE(10.0, 0.4), 4.0, "INTERP_SCALE");
+ ASSERT_FLOAT_EQ(INTERP_REVERSE_SCALE(10.0, 0.3), 7.0, "INTERP_REVERSE_SCALE");
+ ASSERT_FLOAT_EQ(INTERP_MIX(0.0, 20.0, 0.5), 10.0, "INTERP_MIX");
+ ASSERT_FLOAT_EQ(INTERP_CROSSFADE(0.0, 20.0, 0.25), 5.0, "INTERP_CROSSFADE");
+ ASSERT_FLOAT_EQ(INTERP_SMOOTH_START_2(0.5), 0.25, "INTERP_SMOOTH_START_2");
+ ASSERT_FLOAT_EQ(INTERP_SMOOTH_STOP_2(0.5), 0.75, "INTERP_SMOOTH_STOP_2");
+ ASSERT_FLOAT_EQ(INTERP_SMOOTH_STEP_3(0.0), 0.0, "INTERP_SMOOTH_STEP_3(0)");
+ ASSERT_FLOAT_EQ(INTERP_SMOOTH_STEP_3(1.0), 1.0, "INTERP_SMOOTH_STEP_3(1)");
+ ASSERT_FLOAT_EQ(INTERP_ARCH_2(0.5), 0.25, "INTERP_ARCH_2");
+ ASSERT_FLOAT_EQ(INTERP_SMOOTH_START_ARCH_3(0.5), 0.125, "INTERP_SMOOTH_START_ARCH_3");
+ ASSERT_FLOAT_EQ(INTERP_SMOOTH_STOP_ARCH_3(0.5), 0.125, "INTERP_SMOOTH_STOP_ARCH_3");
+}
+
+int main() {
+ test_flip();
+ test_smooth_start();
+ test_smooth_stop();
+ test_mix();
+ test_crossfade();
+ test_scale();
+ test_reverse_scale();
+ test_arch();
+ test_smooth_step();
+ test_namespaced_macros();
+
+ printf("test_interpolate: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_pool.c b/tests/test_pool.c
new file mode 100644
index 0000000..2c75688
--- /dev/null
+++ b/tests/test_pool.c
@@ -0,0 +1,150 @@
+// Tests for pool.h - memory pool allocation and free-list operations
+#define POOL_IMPLEMENTATION
+#include "../include/pool.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_pool.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_init_and_fini() {
+ pool_t *p = pool_init(32, 10);
+ ASSERT(p != NULL, "pool_init returns non-NULL");
+ if (!p) return;
+ ASSERT(p->block_s == 32, "block size");
+ ASSERT(p->block_n == 10, "block count");
+ pool_fini(&p);
+}
+
+void test_malloc_and_free() {
+ pool_t *p = pool_init(64, 5);
+
+ void *b1 = pool_malloc(p);
+ void *b2 = pool_malloc(p);
+ void *b3 = pool_malloc(p);
+
+ ASSERT(b1 != NULL, "malloc 1");
+ ASSERT(b2 != NULL, "malloc 2");
+ ASSERT(b3 != NULL, "malloc 3");
+ ASSERT(b1 != b2, "malloc returns different blocks");
+ ASSERT(pool_size(p) == 3, "size after 3 mallocs");
+
+ pool_free(p, b2);
+ ASSERT(pool_size(p) == 2, "size after 1 free");
+
+ pool_free(p, b1);
+ pool_free(p, b3);
+ ASSERT(pool_size(p) == 0, "size after all frees");
+
+ pool_fini(&p);
+}
+
+void test_reuse_freed_block() {
+ pool_t *p = pool_init(32, 3);
+
+ void *b1 = pool_malloc(p);
+ void *b2 = pool_malloc(p);
+ pool_free(p, b1);
+
+ void *b3 = pool_malloc(p);
+ ASSERT(b3 == b1, "reuses freed block");
+
+ pool_free(p, b2);
+ pool_free(p, b3);
+ pool_fini(&p);
+}
+
+void test_capacity_and_size() {
+ pool_t *p = pool_init(16, 20);
+ ASSERT(pool_capacity(p) == 20, "capacity");
+ ASSERT(pool_size(p) == 0, "initial size is 0");
+
+ pool_malloc(p);
+ pool_malloc(p);
+ ASSERT(pool_size(p) == 2, "size after 2 mallocs");
+
+ pool_fini(&p);
+}
+
+void test_exhaust_pool() {
+ pool_t *p = pool_init(16, 2);
+ pool_malloc(p);
+ pool_malloc(p);
+ void *overflow = pool_malloc(p);
+ ASSERT(overflow == NULL, "returns NULL when exhausted");
+
+ pool_fini(&p);
+}
+
+void test_write_and_read_block() {
+ pool_t *p = pool_init(32, 1);
+ int32_t *block = pool_malloc(p);
+ ASSERT(block != NULL, "pool_malloc returns non-NULL");
+ if (!block) { pool_fini(&p); return; }
+ *block = 42;
+ ASSERT(*block == 42, "can write and read block data");
+ pool_fini(&p);
+}
+
+void test_multiple_free_fini() {
+ pool_t *p = pool_init(16, 1);
+ pool_fini(&p);
+ pool_fini(&p);
+ pool_fini(NULL);
+}
+
+void test_free_invalid_pointer() {
+ pool_t *p = pool_init(32, 3);
+ void *b1 = pool_malloc(p);
+ ASSERT(pool_size(p) == 1, "size before invalid free");
+
+ int stack_var;
+ pool_free(p, &stack_var);
+ ASSERT(pool_size(p) == 1, "invalid free rejected (stack ptr)");
+
+ pool_free(p, (void *)0x12345678);
+ ASSERT(pool_size(p) == 1, "invalid free rejected (random ptr)");
+
+ pool_free(p, NULL);
+ ASSERT(pool_size(p) == 1, "invalid free rejected (NULL)");
+
+ pool_free(NULL, b1);
+ ASSERT(pool_size(p) == 1, "invalid free rejected (NULL pool)");
+
+ pool_free(p, b1);
+ ASSERT(pool_size(p) == 0, "valid free accepted");
+
+ pool_fini(&p);
+}
+
+void test_init_invalid_block_size() {
+ pool_t *p = pool_init(3, 10);
+ ASSERT(p == NULL, "block size < sizeof(void*) returns NULL");
+
+ p = pool_init(7, 10);
+ ASSERT(p == NULL, "block size not multiple of sizeof(void*) returns NULL");
+}
+
+int main() {
+ test_init_and_fini();
+ test_malloc_and_free();
+ test_reuse_freed_block();
+ test_capacity_and_size();
+ test_exhaust_pool();
+ test_write_and_read_block();
+ test_multiple_free_fini();
+ test_free_invalid_pointer();
+ test_init_invalid_block_size();
+
+ printf("test_pool: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_pushdown.c b/tests/test_pushdown.c
new file mode 100644
index 0000000..7e2e18a
--- /dev/null
+++ b/tests/test_pushdown.c
@@ -0,0 +1,442 @@
+// Tests for pushdown.h - pushdown automaton instruction execution
+#include <stdio.h>
+typedef double stack_value_t;
+#define STACK_IMPLEMENTATION
+#include "../include/stack.h"
+
+typedef double pushdown_value_t;
+#define PUSHDOWN_IMPLEMENTATION
+#include "../include/pushdown.h"
+
+#include <math.h>
+
+#define EPS 1e-9
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_pushdown.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_FLOAT_EQ(a, b, msg) \
+ do { \
+ if (fabs((a) - (b)) > EPS) { \
+ fprintf(stderr, "FAIL: %s (got %f, expected %f) (test_pushdown.c:%d)\n", \
+ msg, (double)(a), (double)(b), __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void setup_stacks(stack_t *input, stack_t *output) {
+ *input = (stack_t){.data = NULL, .size = 0, .capacity = 16};
+ *output = (stack_t){.data = NULL, .size = 0, .capacity = 16};
+ stack_init(input);
+ stack_init(output);
+}
+
+void teardown_stacks(stack_t *input, stack_t *output) {
+ stack_free(input);
+ stack_free(output);
+}
+
+void test_push() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 5);
+ pushdown_instruction_t ops[] = {PUSH, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "push size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 5, "push value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_pop() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&output, 5);
+ pushdown_instruction_t ops[] = {POP, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 0, "pop size");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_skip() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 5);
+ stack_push(&input, 10);
+ pushdown_instruction_t ops[] = {SKIP, PUSH, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "skip+push size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 5, "skip top(10), push next(5)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_add() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 3);
+ stack_push(&input, 4);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, ADD, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "add size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 7, "add value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_sub() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 10);
+ stack_push(&input, 3);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, SUB, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "sub size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 7, "sub value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_mul() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 3);
+ stack_push(&input, 4);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, MUL, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "mul size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 12, "mul value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_div() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 10);
+ stack_push(&input, 2);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, DIV, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "div size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 5, "div value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_div_zero() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 0);
+ stack_push(&input, 10);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, DIV, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "div zero size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 0, "div zero result is 0");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_recip() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 4);
+ pushdown_instruction_t ops[] = {PUSH, RECIP, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "recip size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 0.25, "recip value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_inc() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 5);
+ pushdown_instruction_t ops[] = {PUSH, INC, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT_FLOAT_EQ(stack_peek(&output), 6, "inc value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_dec() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 5);
+ pushdown_instruction_t ops[] = {PUSH, DEC, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT_FLOAT_EQ(stack_peek(&output), 4, "dec value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_sign() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, -5);
+ pushdown_instruction_t ops[] = {PUSH, SIGN, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), -1, "sign negative");
+ teardown_stacks(&input, &output);
+
+ setup_stacks(&input, &output);
+ stack_push(&input, 5);
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 1, "sign positive");
+ teardown_stacks(&input, &output);
+
+ setup_stacks(&input, &output);
+ stack_push(&input, 0);
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 0, "sign zero");
+ teardown_stacks(&input, &output);
+}
+
+void test_neg() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 5);
+ pushdown_instruction_t ops[] = {PUSH, NEG, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), -5, "neg value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_sin() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 0);
+ pushdown_instruction_t ops[] = {PUSH, SIN, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 0, "sin(0)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_sqrt() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 9);
+ pushdown_instruction_t ops[] = {PUSH, SQRT, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 3, "sqrt(9)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_abs() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, -5);
+ pushdown_instruction_t ops[] = {PUSH, ABS, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 5, "abs(-5)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_floor() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 3.7);
+ pushdown_instruction_t ops[] = {PUSH, FLOOR, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 3, "floor(3.7)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_ceil() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 3.2);
+ pushdown_instruction_t ops[] = {PUSH, CEIL, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 4, "ceil(3.2)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_copy() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 42);
+ pushdown_instruction_t ops[] = {PUSH, COPY, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 2, "copy size");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_noop() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 5);
+ pushdown_instruction_t ops[] = {NOOP, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 0, "noop no change");
+ ASSERT(input.size == 1, "noop input unchanged");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_sqrt_negative() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, -1);
+ pushdown_instruction_t ops[] = {PUSH, SQRT, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "sqrt negative pushes 0");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 0, "sqrt negative result is 0");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_pow() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 2);
+ stack_push(&input, 3);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, POW, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 1, "pow size");
+ ASSERT_FLOAT_EQ(stack_peek(&output), 8, "pow value 2^3");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_log() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 1);
+ pushdown_instruction_t ops[] = {PUSH, LOG, END};
+ pushdown_run(&output, &input, ops);
+ ASSERT_FLOAT_EQ(stack_peek(&output), 0, "log(1)");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_complex_program() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 3);
+ stack_push(&input, 4);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, MUL, INC, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT_FLOAT_EQ(stack_peek(&output), 13, "3*4+1");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_minmax() {
+ stack_t input, output;
+ setup_stacks(&input, &output);
+
+ stack_push(&input, 3);
+ stack_push(&input, 7);
+ pushdown_instruction_t ops[] = {PUSH, PUSH, MINMAX, END};
+ pushdown_run(&output, &input, ops);
+
+ ASSERT(output.size == 2, "minmax size is 2");
+ ASSERT_FLOAT_EQ(output.data[0], 3, "minmax min value");
+ ASSERT_FLOAT_EQ(output.data[1], 7, "minmax max value");
+
+ teardown_stacks(&input, &output);
+}
+
+void test_validate_minmax() {
+ pushdown_instruction_t valid[] = {PUSH, MINMAX, END};
+ ASSERT(pushdown_validate(valid), "validate accepts MINMAX");
+
+ pushdown_instruction_t invalid[] = {PUSH, (pushdown_instruction_t)999, END};
+ ASSERT(!pushdown_validate(invalid), "validate rejects out-of-range");
+}
+
+void test_instruction_count_bounds() {
+ pushdown_instruction_t ops[] = {PUSH, ADD, (pushdown_instruction_t)999, END};
+ ASSERT(pushdown_instruction_count(ops) == 2, "stops at invalid instruction");
+
+ ASSERT(pushdown_instruction_count(NULL) == 0, "NULL returns 0");
+}
+
+int main() {
+ test_push();
+ test_pop();
+ test_skip();
+ test_add();
+ test_sub();
+ test_mul();
+ test_div();
+ test_div_zero();
+ test_recip();
+ test_inc();
+ test_dec();
+ test_sign();
+ test_neg();
+ test_sin();
+ test_sqrt();
+ test_abs();
+ test_floor();
+ test_ceil();
+ test_copy();
+ test_noop();
+ test_sqrt_negative();
+ test_pow();
+ test_log();
+ test_complex_program();
+ test_minmax();
+ test_validate_minmax();
+ test_instruction_count_bounds();
+
+ printf("test_pushdown: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_stack.c b/tests/test_stack.c
new file mode 100644
index 0000000..8fc19a3
--- /dev/null
+++ b/tests/test_stack.c
@@ -0,0 +1,234 @@
+// Tests for stack.h - stack push, pop, peek, and resizing operations
+#include <math.h>
+#include <stdio.h>
+
+typedef double stack_value_t;
+
+#define STACK_IMPLEMENTATION
+#include "../include/stack.h"
+
+#define EPS 1e-9
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_stack.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_FLOAT_EQ(a, b, msg) \
+ do { \
+ if (fabs((a) - (b)) > EPS) { \
+ fprintf(stderr, "FAIL: %s (got %f, expected %f) (test_stack.c:%d)\n", \
+ msg, (double)(a), (double)(b), __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_init_and_free() {
+ stack_t s = {.capacity = 10};
+ stack_init(&s);
+ ASSERT(s.size == 0, "init size should be 0");
+ ASSERT(s.capacity == 10, "init capacity should be 10");
+ ASSERT(s.data != NULL, "init data should not be NULL");
+ stack_free(&s);
+}
+
+void test_default_capacity() {
+ stack_t s = {0};
+ stack_init(&s);
+ ASSERT(s.capacity == 16, "default capacity should be 16");
+ stack_free(&s);
+}
+
+void test_push_pop() {
+ stack_t s = {0};
+ stack_init(&s);
+
+ for (int i = 0; i < 5; i++) {
+ stack_push(&s, (stack_value_t)(i * 10));
+ }
+
+ ASSERT(s.size == 5, "size after 5 pushes");
+ ASSERT_FLOAT_EQ(stack_pop(&s), 40, "pop last pushed");
+ ASSERT_FLOAT_EQ(stack_pop(&s), 30, "pop second last");
+ ASSERT(s.size == 3, "size after 2 pops");
+
+ stack_free(&s);
+}
+
+void test_peek() {
+ stack_t s = {0};
+ stack_init(&s);
+ stack_push(&s, 42);
+
+ ASSERT_FLOAT_EQ(stack_peek(&s), 42, "peek returns top");
+ ASSERT(s.size == 1, "peek does not change size");
+
+ stack_free(&s);
+}
+
+void test_peek_empty() {
+ stack_t s = {0};
+ stack_init(&s);
+ ASSERT_FLOAT_EQ(stack_peek(&s), 0, "peek empty returns ZERO");
+ stack_free(&s);
+}
+
+void test_pop_empty() {
+ stack_t s = {0};
+ stack_init(&s);
+ ASSERT_FLOAT_EQ(stack_pop(&s), 0, "pop empty returns ZERO");
+ stack_free(&s);
+}
+
+void test_grow() {
+ stack_t s = {.capacity = 2};
+ stack_init(&s);
+
+ for (int i = 0; i < 10; i++) {
+ stack_push(&s, (stack_value_t)i);
+ }
+
+ ASSERT(s.size == 10, "size after growing");
+ ASSERT(s.capacity >= 10, "capacity grew");
+
+ for (int i = 9; i >= 0; i--) {
+ ASSERT_FLOAT_EQ(stack_pop(&s), (stack_value_t)i, "values preserved after grow");
+ }
+
+ stack_free(&s);
+}
+
+void test_clear() {
+ stack_t s = {0};
+ stack_init(&s);
+ stack_push(&s, 1);
+ stack_push(&s, 2);
+ stack_push(&s, 3);
+
+ stack_clear(&s);
+ ASSERT_FLOAT_EQ(s.data[0], 0, "clear zeroed data[0]");
+ ASSERT_FLOAT_EQ(s.data[1], 0, "clear zeroed data[1]");
+ ASSERT_FLOAT_EQ(s.data[2], 0, "clear zeroed data[2]");
+
+ stack_free(&s);
+}
+
+void test_from_array() {
+ stack_value_t arr[] = {1, 2, 3, 4, 5};
+ stack_t s = {0};
+ stack_from_array(&s, arr, 5);
+
+ ASSERT(s.size == 5, "from_array size");
+ ASSERT(s.capacity == 5, "from_array capacity");
+
+ for (int i = 4; i >= 0; i--) {
+ ASSERT_FLOAT_EQ(stack_pop(&s), (stack_value_t)(i + 1), "from_array values");
+ }
+
+ stack_free(&s);
+}
+
+void test_empty() {
+ stack_t s = {0};
+ stack_init(&s);
+ ASSERT(stack_empty(&s) == 1, "empty stack returns true");
+ stack_push(&s, 42);
+ ASSERT(stack_empty(&s) == 0, "non-empty stack returns false");
+ stack_free(&s);
+}
+
+void test_from_array_single() {
+ stack_value_t arr[] = {42};
+ stack_t s = {0};
+ stack_from_array(&s, arr, 1);
+
+ ASSERT(s.size == 1, "from_array single size");
+ ASSERT(s.capacity == 1, "from_array single capacity");
+ ASSERT_FLOAT_EQ(stack_pop(&s), 42, "from_array single value");
+
+ stack_free(&s);
+}
+
+void test_multiple_free() {
+ stack_t s = {0};
+ stack_init(&s);
+ stack_free(&s);
+ ASSERT(s.data == NULL, "data is NULL after free");
+ ASSERT(s.size == 0, "size is 0 after free");
+ ASSERT(s.capacity == 0, "capacity is 0 after free");
+ stack_free(&s);
+ ASSERT(s.data == NULL, "second free is safe");
+}
+
+void test_pop_ok() {
+ stack_t s = {0};
+ stack_init(&s);
+
+ stack_value_t out = 0;
+ ASSERT(!stack_pop_ok(&s, &out), "pop_ok on empty returns 0");
+
+ stack_push(&s, 42);
+ ASSERT(stack_pop_ok(&s, &out), "pop_ok on non-empty returns 1");
+ ASSERT_FLOAT_EQ(out, 42, "pop_ok value");
+ ASSERT(s.size == 0, "pop_ok decrements size");
+
+ stack_free(&s);
+}
+
+void test_peek_ok() {
+ stack_t s = {0};
+ stack_init(&s);
+
+ stack_value_t out = 0;
+ ASSERT(!stack_peek_ok(&s, &out), "peek_ok on empty returns 0");
+
+ stack_push(&s, 99);
+ ASSERT(stack_peek_ok(&s, &out), "peek_ok on non-empty returns 1");
+ ASSERT_FLOAT_EQ(out, 99, "peek_ok value");
+ ASSERT(s.size == 1, "peek_ok does not change size");
+
+ stack_free(&s);
+}
+
+void test_pop_ok_null_out() {
+ stack_t s = {0};
+ stack_init(&s);
+ stack_push(&s, 1);
+ ASSERT(!stack_pop_ok(&s, NULL), "pop_ok with NULL out returns 0");
+ stack_free(&s);
+}
+
+void test_peek_ok_null_out() {
+ stack_t s = {0};
+ stack_init(&s);
+ stack_push(&s, 1);
+ ASSERT(!stack_peek_ok(&s, NULL), "peek_ok with NULL out returns 0");
+ stack_free(&s);
+}
+
+int main() {
+ test_init_and_free();
+ test_default_capacity();
+ test_push_pop();
+ test_peek();
+ test_peek_empty();
+ test_pop_empty();
+ test_pop_ok();
+ test_peek_ok();
+ test_pop_ok_null_out();
+ test_peek_ok_null_out();
+ test_grow();
+ test_clear();
+ test_from_array();
+ test_from_array_single();
+ test_empty();
+ test_multiple_free();
+
+ printf("test_stack: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_ufo.c b/tests/test_ufo.c
new file mode 100644
index 0000000..810c7cf
--- /dev/null
+++ b/tests/test_ufo.c
@@ -0,0 +1,143 @@
+// Tests for ufo.h - Universal Function Originator evaluation and mutation
+#include <stdio.h>
+#define UFO_IMPLEMENTATION
+#include "../include/ufo.h"
+
+#include <math.h>
+
+#define EPS 1e-6
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_ufo.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_FLOAT_EQ(a, b, msg) \
+ do { \
+ if (fabs((a) - (b)) > EPS) { \
+ fprintf(stderr, "FAIL: %s (got %f, expected %f) (test_ufo.c:%d)\n", \
+ msg, (double)(a), (double)(b), __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_init() {
+ ufo_t *ufo = ufo_init(2, 3);
+ ASSERT(ufo != NULL, "init returns non-NULL");
+ ASSERT(ufo->n == 2, "input count");
+ ASSERT(ufo->v == 3, "block count");
+ ASSERT(ufo->a != NULL, "weights allocated");
+ ASSERT(ufo->b != NULL, "exponents allocated");
+ ASSERT(ufo->w != NULL, "external weights allocated");
+ ASSERT(ufo->c != NULL, "external exponents allocated");
+ ASSERT(ufo->f != NULL, "unary functions allocated");
+ ASSERT(ufo->fe != NULL, "external binary functions allocated");
+ ASSERT(ufo->fi != NULL, "internal binary functions allocated");
+ ufo_free(ufo);
+}
+
+void test_init_defaults() {
+ ufo_t *ufo = ufo_init(1, 2);
+ ASSERT(ufo != NULL, "init returns non-NULL for init_defaults");
+ if (!ufo)
+ return;
+
+ ASSERT_FLOAT_EQ(ufo->w[2], 0, "bias is zero");
+
+ for (uint32_t j = 0; j < ufo->v; j++) {
+ ASSERT_FLOAT_EQ(ufo_ajk(ufo, j, ufo->n), 0, "bias weight is zero");
+ }
+
+ ufo_free(ufo);
+}
+
+void test_evaluate_default() {
+ ufo_t *ufo = ufo_init(2, 3);
+ f64 x[] = {1.0, 2.0};
+ f64 result = ufo_evaluate(ufo, x);
+ ASSERT(isfinite(result), "evaluate returns finite value");
+ ASSERT(result > 0, "evaluate returns positive value with default weights");
+ ufo_free(ufo);
+}
+
+void test_evaluate_multiple_inputs() {
+ ufo_t *ufo = ufo_init(3, 2);
+ f64 x[] = {1.0, 2.0, 3.0};
+ f64 result = ufo_evaluate(ufo, x);
+ ASSERT(isfinite(result), "multi-input evaluate returns finite");
+ ASSERT(result > 0, "multi-input evaluate returns positive");
+ ufo_free(ufo);
+}
+
+void test_free() {
+ ufo_t *ufo = ufo_init(2, 5);
+ ASSERT(ufo != NULL, "init returns non-NULL before free");
+ ufo_free(ufo);
+}
+
+void test_different_configs() {
+ ufo_t *u2 = ufo_init(5, 10);
+ ufo_t *u3 = ufo_init(10, 2);
+ ASSERT(u2 != NULL, "init config 2");
+ ASSERT(u3 != NULL, "init config 3");
+
+ f64 x2[5] = {1, 2, 3, 4, 5};
+ f64 x3[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
+
+ f64 r2 = ufo_evaluate(u2, x2);
+ f64 r3 = ufo_evaluate(u3, x3);
+
+ ASSERT(isfinite(r2), "config 2 returns finite");
+ ASSERT(isfinite(r3), "config 3 returns finite");
+ ASSERT(r2 != r3, "different configs produce different results");
+
+ ufo_free(u2);
+ ufo_free(u3);
+}
+
+void test_weight_modification() {
+ ufo_t *ufo = ufo_init(2, 3);
+ ASSERT(ufo != NULL, "ufo_init returns non-NULL");
+ if (!ufo) return;
+
+ f64 x[] = {3.0, 4.0};
+ f64 result_before = ufo_evaluate(ufo, x);
+
+ ufo_ajk(ufo, 0, 0) = 0.0;
+ ufo->w[0] = 0.0;
+ ufo->w[1] = 0.0;
+ ufo->w[2] = 0.0;
+
+ f64 result_after = ufo_evaluate(ufo, x);
+ ASSERT(isfinite(result_after), "modified weights evaluate finite");
+ ASSERT(result_before != result_after, "modified weights change result");
+
+ ufo_free(ufo);
+}
+
+void test_large_ufo() {
+ ufo_t *ufo = ufo_init(4, 50);
+ f64 x[4] = {0.1, 0.2, 0.3, 0.4};
+ f64 result = ufo_evaluate(ufo, x);
+ ASSERT(isfinite(result), "large UFO returns finite");
+ ufo_free(ufo);
+}
+
+int main() {
+ test_init();
+ test_init_defaults();
+ test_evaluate_default();
+ test_evaluate_multiple_inputs();
+ test_free();
+ test_different_configs();
+ test_weight_modification();
+ test_large_ufo();
+
+ printf("test_ufo: %d failures\n", failures);
+ return failures;
+}
diff --git a/tests/test_uri.c b/tests/test_uri.c
new file mode 100644
index 0000000..a768c8c
--- /dev/null
+++ b/tests/test_uri.c
@@ -0,0 +1,300 @@
+// Tests for uri.h - URI parsing and serialization
+#define URI_IMPLEMENTATION
+#include "../include/uri.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define ASSERT(cond, msg) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL: %s (test_uri.c:%d)\n", msg, __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+#define ASSERT_STR_EQ(a, b, msg) \
+ do { \
+ if ((a) == NULL || (b) == NULL || strcmp((a), (b)) != 0) { \
+ fprintf(stderr, "FAIL: %s (got '%s', expected '%s') (test_uri.c:%d)\n", \
+ msg, (a) ? (a) : "NULL", (b) ? (b) : "NULL", __LINE__); \
+ failures++; \
+ } \
+ } while (0)
+
+static int failures = 0;
+
+void test_init() {
+ uri_t *u = uri_init();
+ ASSERT(u != NULL, "init returns non-NULL");
+ for (int i = 0; i < URI_END; i++) {
+ ASSERT(u->data[i] == NULL, "all fields NULL");
+ }
+ uri_free(u);
+}
+
+void test_set_and_get() {
+ uri_t *u = uri_init();
+
+ uri_set(u, SCHEME, "https");
+ uri_set(u, HOST, "example.com");
+ uri_set(u, PATH, "/api/v1");
+
+ char *uri = uri_get(u);
+ ASSERT(uri != NULL, "get returns non-NULL");
+ ASSERT_STR_EQ(uri, "https://example.com/api/v1", "constructed URI");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_set_with_port() {
+ uri_t *u = uri_init();
+
+ uri_set(u, SCHEME, "http");
+ uri_set(u, HOST, "localhost");
+ uri_set(u, PORT, "8080");
+
+ char *uri = uri_get(u);
+ ASSERT_STR_EQ(uri, "http://localhost:8080", "URI with port");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_set_with_query() {
+ uri_t *u = uri_init();
+
+ uri_set(u, SCHEME, "https");
+ uri_set(u, HOST, "example.com");
+ uri_set(u, PATH, "/search");
+ uri_set(u, QUERY, "q=test");
+
+ char *uri = uri_get(u);
+ ASSERT_STR_EQ(uri, "https://example.com/search?q=test", "URI with query");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_set_with_fragment() {
+ uri_t *u = uri_init();
+
+ uri_set(u, SCHEME, "https");
+ uri_set(u, HOST, "example.com");
+ uri_set(u, PATH, "/page");
+ uri_set(u, FRAGMENT, "section");
+
+ char *uri = uri_get(u);
+ ASSERT_STR_EQ(uri, "https://example.com/page#section", "URI with fragment");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_full_uri() {
+ uri_t *u = uri_init();
+
+ uri_set(u, SCHEME, "https");
+ uri_set(u, USERINFO, "user:pass");
+ uri_set(u, HOST, "example.com");
+ uri_set(u, PORT, "443");
+ uri_set(u, PATH, "/path/to/resource");
+ uri_set(u, QUERY, "key=value");
+ uri_set(u, FRAGMENT, "anchor");
+
+ char *uri = uri_get(u);
+ ASSERT(uri != NULL, "full URI constructed");
+
+ uri_free(u);
+ free(uri);
+}
+
+void test_parse_basic() {
+ uri_t *u = uri_parse("https://example.com/path");
+ ASSERT(u != NULL, "parse returns non-NULL");
+
+ ASSERT_STR_EQ(u->data[SCHEME], "https", "parsed scheme");
+ ASSERT_STR_EQ(u->data[HOST], "example.com", "parsed host");
+ ASSERT_STR_EQ(u->data[PATH], "/path", "parsed path");
+
+ uri_free(u);
+}
+
+void test_parse_with_port() {
+ uri_t *u = uri_parse("http://localhost:8080/api");
+ ASSERT(u != NULL, "parse with port");
+
+ ASSERT_STR_EQ(u->data[SCHEME], "http", "scheme");
+ ASSERT_STR_EQ(u->data[HOST], "localhost", "host");
+ ASSERT_STR_EQ(u->data[PORT], "8080", "port");
+ ASSERT_STR_EQ(u->data[PATH], "/api", "path");
+
+ uri_free(u);
+}
+
+void test_parse_with_query() {
+ uri_t *u = uri_parse("https://example.com/search?q=hello");
+ ASSERT(u != NULL, "parse with query");
+
+ ASSERT_STR_EQ(u->data[QUERY], "q=hello", "query parsed");
+
+ uri_free(u);
+}
+
+void test_parse_with_fragment() {
+ uri_t *u = uri_parse("https://example.com/page#top");
+ ASSERT(u != NULL, "parse with fragment");
+
+ ASSERT_STR_EQ(u->data[FRAGMENT], "top", "fragment parsed");
+
+ uri_free(u);
+}
+
+void test_parse_full() {
+ uri_t *u = uri_parse("https://user:pass@example.com:443/path?q=1#frag");
+ ASSERT(u != NULL, "parse full URI");
+
+ ASSERT_STR_EQ(u->data[SCHEME], "https", "scheme");
+ ASSERT_STR_EQ(u->data[USERINFO], "user:pass", "userinfo parsed");
+ ASSERT_STR_EQ(u->data[HOST], "example.com", "host parsed");
+ ASSERT_STR_EQ(u->data[PORT], "443", "port parsed");
+ ASSERT_STR_EQ(u->data[PATH], "/path", "path");
+ ASSERT_STR_EQ(u->data[QUERY], "q=1", "query");
+ ASSERT_STR_EQ(u->data[FRAGMENT], "frag", "fragment");
+
+ uri_free(u);
+}
+
+void test_parse_invalid() {
+ uri_t *u = uri_parse("not-a-uri");
+ ASSERT(u == NULL, "invalid URI returns NULL");
+}
+
+void test_set_override() {
+ uri_t *u = uri_init();
+
+ uri_set(u, SCHEME, "http");
+ uri_set(u, HOST, "old.com");
+
+ uri_set(u, SCHEME, "https");
+ uri_set(u, HOST, "new.com");
+
+ char *uri = uri_get(u);
+ ASSERT_STR_EQ(uri, "https://new.com", "override works");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_roundtrip() {
+ char const *original = "https://example.com:8080/path?q=test#anchor";
+ uri_t *u = uri_parse(original);
+ ASSERT(u != NULL, "parse for roundtrip");
+ if (!u)
+ return;
+
+ char *rebuilt = uri_get(u);
+ ASSERT(rebuilt != NULL, "rebuilt URI");
+ if (!rebuilt) {
+ uri_free(u);
+ return;
+ }
+
+ ASSERT(strcmp(rebuilt, original) == 0, "roundtrip matches original");
+
+ uri_free(u);
+ free(rebuilt);
+}
+
+void test_minimal_uri() {
+ uri_t *u = uri_init();
+ uri_set(u, SCHEME, "file");
+ uri_set(u, HOST, "/tmp/file.txt");
+
+ char *uri = uri_get(u);
+ ASSERT(uri != NULL, "minimal URI");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_set_null() {
+ uri_t *u = uri_init();
+ uri_set(u, SCHEME, "http");
+ uri_set(u, HOST, "example.com");
+ uri_set(u, HOST, NULL);
+
+ char *uri = uri_get(u);
+ ASSERT(uri != NULL, "uri with NULL host");
+ ASSERT(strcmp(uri, "http:") == 0, "uri is just scheme");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_remove_element() {
+ uri_t *u = uri_init();
+ uri_set(u, SCHEME, "https");
+ uri_set(u, HOST, "example.com");
+ uri_set(u, PATH, "/path");
+
+ char *removed = uri_remove(u, SCHEME);
+ ASSERT(removed != NULL, "remove returns old value");
+ ASSERT(strcmp(removed, "https") == 0, "removed value matches");
+ free(removed);
+
+ char *uri = uri_get(u);
+ ASSERT(uri != NULL, "uri after remove");
+ ASSERT(strcmp(uri, "//example.com/path") == 0, "uri without scheme");
+ free(uri);
+
+ uri_free(u);
+}
+
+void test_null_guards() {
+ uri_set(NULL, SCHEME, "http");
+ uri_print(NULL);
+ uri_print_elements(NULL);
+ ASSERT(uri_get(NULL) == NULL, "uri_get(NULL) returns NULL");
+ ASSERT(uri_remove(NULL, HOST) == NULL, "uri_remove(NULL) returns NULL");
+ ASSERT(uri_parse(NULL) == NULL, "uri_parse(NULL) returns NULL");
+}
+
+void test_bounds_guards() {
+ uri_t *u = uri_init();
+
+ uri_set(u, (uri_element_t)999, "http");
+ ASSERT(u->data[SCHEME] == NULL, "out-of-bounds set is no-op");
+
+ char *r = uri_remove(u, (uri_element_t)-1);
+ ASSERT(r == NULL, "negative index remove returns NULL");
+
+ uri_free(u);
+}
+
+int main() {
+ test_init();
+ test_set_and_get();
+ test_set_with_port();
+ test_set_with_query();
+ test_set_with_fragment();
+ test_full_uri();
+ test_parse_basic();
+ test_parse_with_port();
+ test_parse_with_query();
+ test_parse_with_fragment();
+ test_parse_full();
+ test_parse_invalid();
+ test_set_override();
+ test_roundtrip();
+ test_minimal_uri();
+ test_set_null();
+ test_remove_element();
+ test_null_guards();
+ test_bounds_guards();
+
+ printf("test_uri: %d failures\n", failures);
+ return failures;
+}