summaryrefslogtreecommitdiffstats
path: root/tests/test_config.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_config.cpp')
-rw-r--r--tests/test_config.cpp654
1 files changed, 654 insertions, 0 deletions
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
new file mode 100644
index 0000000..c2b5e2b
--- /dev/null
+++ b/tests/test_config.cpp
@@ -0,0 +1,654 @@
+#include "config.h"
+
+#include <math.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)
+
+#define CHECK_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static cli mkcli(void) {
+ cli c;
+ memset(&c, 0, sizeof c);
+ return c;
+}
+
+static void freecfg(config *c) {
+ config_free(c);
+}
+
+/* ------------------------------------------------------------------ */
+/* Provider parsing */
+/* ------------------------------------------------------------------ */
+
+static void test_provider_parsing(void) {
+ char *err = NULL;
+ CHECK(provider_parse("openai", &err) == PROV_OPENAI, "openai");
+ CHECK(provider_parse("OpenAI-compatible", &err) == PROV_OPENAI,
+ "OpenAI-compatible");
+ CHECK(provider_parse("openai_compatible", &err) == PROV_OPENAI,
+ "openai_compatible");
+ CHECK(provider_parse("Anthropic", &err) == PROV_ANTHROPIC, "Anthropic");
+ CHECK(provider_parse("claude", &err) == PROV_ANTHROPIC, "claude");
+ CHECK(provider_parse("ollama", &err) == PROV_OLLAMA, "ollama");
+
+ err = NULL;
+ provider_kind k = provider_parse("nope", &err);
+ CHECK(k == PROV_OPENAI && err && strstr(err, "nope") &&
+ strstr(err, "unknown provider"),
+ "unknown provider error");
+ free(err);
+
+ CHECK(strcmp(provider_default_model(PROV_OPENAI), "gpt-5.2") == 0,
+ "default openai model");
+ CHECK(strcmp(provider_default_model(PROV_ANTHROPIC), "claude-sonnet-4-6") ==
+ 0,
+ "default anthropic model");
+ CHECK(strcmp(provider_default_model(PROV_OLLAMA), "llama3.2") == 0,
+ "default ollama model");
+}
+
+/* ------------------------------------------------------------------ */
+/* CLI parsing */
+/* ------------------------------------------------------------------ */
+
+static void test_cli_parse_basic(void) {
+ cli c;
+ char *err = NULL;
+ const char *a1 = "--provider";
+ const char *a2 = "ollama";
+ const char *a3 = "--model";
+ const char *a4 = "llama3.2";
+ const char *a5 = "hello world";
+ const char *argv[] = {
+ "llsh", a1, a2, a3, a4, a5
+ };
+ int r = cli_parse(6, (char **)argv, &c, &err);
+ CHECK(r == 0, "parse ok");
+ CHECK_EQ_STR(c.provider, "ollama", "provider");
+ CHECK_EQ_STR(c.model, "llama3.2", "model");
+ CHECK_EQ_STR(c.prompt, "hello world", "prompt positional");
+ CHECK(!c.debug, "debug off by default");
+ cli_free(&c);
+ free(err);
+
+ /* -C and --verbose and numeric flags */
+ const char *b1 = "-C";
+ const char *b2 = "/tmp";
+ const char *b3 = "--verbose";
+ const char *b4 = "--max-turns";
+ const char *b5 = "0";
+ const char *b6 = "--max-retries";
+ const char *b7 = "2";
+ const char *b8 = "--debug";
+ const char *b9 = "--allow-mcp";
+ const char *argv2[] = {
+ "llsh", b1, b2, b3, b4, b5, b6, b7, b8, b9
+ };
+ r = cli_parse(10, (char **)argv2, &c, &err);
+ CHECK(r == 0, "parse -C/--verbose ok");
+ CHECK_EQ_STR(c.cwd, "/tmp", "cwd");
+ CHECK(c.verbose, "verbose flag");
+ CHECK(c.debug, "debug flag");
+ CHECK(c.allow_mcp, "allow-mcp trust opt-in");
+ CHECK(c.has_max_turns && c.max_turns == 0, "max-turns 0 accepted");
+ CHECK(c.has_max_retries && c.max_retries == 2, "max-retries");
+ cli_free(&c);
+ free(err);
+
+ /* unknown option */
+ const char *u1 = "--frobnicate";
+ const char *argv3[] = {
+ "llsh", u1
+ };
+ r = cli_parse(2, (char **)argv3, &c, &err);
+ CHECK(r == -1 && err, "unknown option errors");
+ free(err);
+ cli_free(&c);
+
+ /* two positionals */
+ const char *p1 = "one";
+ const char *p2 = "two";
+ const char *argv4[] = {
+ "llsh", p1, p2
+ };
+ r = cli_parse(3, (char **)argv4, &c, &err);
+ CHECK(r == -1 && err && strstr(err, "unexpected argument"),
+ "two positionals error");
+ free(err);
+ cli_free(&c);
+}
+
+static void test_cli_shell_policy_options(void) {
+ cli c;
+ char *err = NULL;
+ const char *argv[] = {"llsh", "--shell-allow", "git", "--shell-allow",
+ "rg", "--shell-deny", "rm", "--confirm-destructive"};
+ CHECK(cli_parse(8, (char **)argv, &c, &err) == 0,
+ "parse shell policy options");
+ CHECK(c.shell_allowlist.len == 2, "repeatable shell allowlist");
+ CHECK(c.shell_denylist.len == 1, "shell denylist");
+ CHECK(c.confirm_destructive, "destructive confirmation enabled");
+ config resolved;
+ CHECK(config_merge(&c, &resolved, &err) == 0,
+ "merge shell policy into runtime config");
+ CHECK(resolved.shell_allowlist.len == 2 &&
+ strcmp(resolved.shell_allowlist.items[0], "git") == 0,
+ "merged allowlist preserved");
+ CHECK(resolved.shell_denylist.len == 1 &&
+ strcmp(resolved.shell_denylist.items[0], "rm") == 0,
+ "merged denylist preserved");
+ CHECK(resolved.confirm_destructive,
+ "merged destructive confirmation enabled");
+ config_free(&resolved);
+ cli_free(&c);
+ free(err);
+}
+
+static void test_cli_parse_rejects_negative_ulong(void) {
+ /* `--max-turns -1` used to parse as ULONG_MAX via strtoull; it must error. */
+ cli c;
+ char *err = NULL;
+ const char *a1 = "--max-turns";
+ const char *a2 = "-1";
+ const char *argv[] = {"llsh", a1, a2};
+ int r = cli_parse(3, (char **)argv, &c, &err);
+ CHECK(r == -1, "negative max-turns rejected");
+ CHECK(err && strstr(err, "invalid value") != NULL, "invalid-value message");
+ free(err);
+ cli_free(&c);
+
+ /* A genuinely huge value that overflows unsigned long must also error. */
+ const char *h1 = "--max-turns";
+ const char *h2 = "99999999999999999999";
+ const char *argv2[] = {"llsh", h1, h2};
+ r = cli_parse(3, (char **)argv2, &c, &err);
+ CHECK(r == -1, "overflowing max-turns rejected");
+ free(err);
+ cli_free(&c);
+}
+
+static void test_cli_parse_fraction_thresholds(void) {
+ cli c;
+ char *err = NULL;
+ const char *good[] = {"llsh", "--compact-threshold", "0.75",
+ "--clear-threshold", "0"};
+ CHECK(cli_parse(5, (char **)good, &c, &err) == 0,
+ "valid fractions accepted");
+ CHECK(c.has_compact_threshold && c.compact_threshold == 0.75,
+ "compact fraction parsed");
+ CHECK(c.has_clear_threshold && c.clear_threshold == 0,
+ "zero disables clear fraction");
+ cli_free(&c);
+ free(err);
+ const char *bad_values[] = {"xyz", "0.75junk", "nan", "inf", "1.01"};
+ for (size_t i = 0; i < sizeof bad_values / sizeof bad_values[0]; i++) {
+ const char *args[] = {"llsh", "--compact-threshold", bad_values[i]};
+ err = NULL;
+ CHECK(cli_parse(3, (char **)args, &c, &err) == -1,
+ "malformed/out-of-range fraction rejected");
+ CHECK(err != NULL, "fraction rejection explains error");
+ free(err);
+ cli_free(&c);
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* File config loading */
+/* ------------------------------------------------------------------ */
+
+/* ------------------------------------------------------------------ */
+/* Merge */
+/* ------------------------------------------------------------------ */
+
+static void test_merge_cli_values(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("anthropic");
+ c.model = xstrdup("cli-model");
+ c.api_key = xstrdup("cli-key");
+
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok");
+ CHECK(cfg.provider == PROV_ANTHROPIC, "cli provider");
+ CHECK_EQ_STR(cfg.model, "cli-model", "cli model");
+ CHECK_EQ_STR(cfg.api_key, "cli-key", "cli key");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_defaults(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok");
+ CHECK(cfg.provider == PROV_OLLAMA, "provider from cli");
+ CHECK_EQ_STR(cfg.model, "llama3.2", "provider default model");
+ CHECK(cfg.api_key == NULL, "no key for ollama");
+ CHECK(cfg.max_turns == 0, "max_turns unbounded by default");
+ CHECK(cfg.preamble && cfg.preamble[0] != '\0', "built-in preamble");
+ CHECK(cfg.max_retries == 3, "default max_retries");
+ CHECK(cfg.context_limit == 0, "no built-in window");
+ CHECK(cfg.compact_threshold > 0.74 && cfg.compact_threshold < 0.76,
+ "default compact_threshold");
+ CHECK(cfg.clear_threshold > 0.49 && cfg.clear_threshold < 0.51,
+ "default clear_threshold");
+ CHECK(!cfg.verbose, "verbose off");
+ CHECK(cfg.skill_index_cap == 50, "default skill cap");
+ CHECK(cfg.shell_timeout_secs == 120, "default shell timeout");
+ CHECK(cfg.max_output_chars == 16000, "default max output");
+ CHECK(cfg.max_file_bytes == 2000000, "default max file bytes");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_api_key_rules(void) {
+ /* openai without base_url or key: the merge defers the check so a
+ * models.json preset can still supply the key */
+ cli c = mkcli();
+ c.provider = xstrdup("openai");
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge defers key check");
+ r = config_require_key_or_base(&cfg, &err);
+ CHECK(r == -1 && err && strstr(err, "no API key"),
+ "openai needs key or base_url");
+ free(err);
+ config_free(&cfg);
+ cli_free(&c);
+
+ /* base_url is enough */
+ c = mkcli();
+ c.provider = xstrdup("openai");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ err = NULL;
+ r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "base_url ok");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept");
+ CHECK(cfg.api_key == NULL, "no key needed");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* anthropic with key is fine */
+ c = mkcli();
+ c.provider = xstrdup("anthropic");
+ c.api_key = xstrdup("k");
+ err = NULL;
+ r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "anthropic+key ok");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_max_turns_and_retries(void) {
+ /* max_turns 0 is unbounded and stays 0 */
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.max_turns = 0;
+ c.has_max_turns = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.max_turns == 0, "max_turns 0 stays 0 (unbounded)");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* explicit retries are honored */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.max_retries = 5;
+ c.has_max_retries = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.max_retries == 5, "retries honored");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_compaction(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 4000;
+ c.has_context_limit = true;
+ c.compact_threshold = 0.9;
+ c.has_compact_threshold = true;
+ c.clear_threshold = 0.25;
+ c.has_clear_threshold = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.context_limit == 4000, "context_limit");
+ CHECK(fabs(cfg.compact_threshold - 0.9) < 1e-9, "compact");
+ CHECK(fabs(cfg.clear_threshold - 0.25) < 1e-9, "clear");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* threshold out of range */
+ int r;
+ double bad[] = {2.0, -0.1, 1.1, NAN, INFINITY};
+ for (size_t i = 0; i < sizeof bad / sizeof *bad; i++) {
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.compact_threshold = bad[i];
+ c.has_compact_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "compact_threshold"),
+ "bad compact_threshold rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+ }
+ double badc[] = {-0.1, 1.5, NAN};
+ for (size_t i = 0; i < sizeof badc / sizeof *badc; i++) {
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.clear_threshold = badc[i];
+ c.has_clear_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "clear_threshold"),
+ "bad clear_threshold rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+ }
+
+ /* clear < compact required when a window is known */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 1000;
+ c.has_context_limit = true;
+ c.clear_threshold = 0.8;
+ c.has_clear_threshold = true;
+ c.compact_threshold = 0.5;
+ c.has_compact_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "clear_threshold"),
+ "clear >= compact rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+
+ /* disabled tier (0.0) imposes no ordering constraint */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 1000;
+ c.has_context_limit = true;
+ c.clear_threshold = 0.0;
+ c.has_clear_threshold = true;
+ c.compact_threshold = 0.5;
+ c.has_compact_threshold = true;
+ err = NULL;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == 0, "clear=0 disables ordering");
+ freecfg(&cfg2);
+ cli_free(&c);
+
+ /* 0 thresholds are valid (tiers off) */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.compact_threshold = 0.0;
+ c.has_compact_threshold = true;
+ c.clear_threshold = 0.0;
+ c.has_clear_threshold = true;
+ err = NULL;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == 0, "0/0 thresholds ok");
+ freecfg(&cfg2);
+ cli_free(&c);
+}
+
+static void test_merge_lower_bounds_and_verbose(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.skill_index_cap = 0;
+ c.has_skill_index_cap = true;
+ c.shell_timeout_secs = 0;
+ c.has_shell_timeout_secs = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.skill_index_cap == 1, "skill cap floor 1");
+ CHECK(cfg.shell_timeout_secs == 1, "shell timeout floor 1");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* verbose from CLI */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.verbose = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.verbose, "cli verbose applied");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+/* ------------------------------------------------------------------ */
+/* Presets */
+/* ------------------------------------------------------------------ */
+
+static models_file mkmodels(const char *def, const char *prov,
+ const char *model, const char *base_url,
+ const char *key, unsigned long mt,
+ unsigned long ctx) {
+ models_file m;
+ memset(&m, 0, sizeof m);
+ m.default_name = xstrdup(def);
+ m.presets = (model_preset* ) xmalloc(sizeof *m.presets);
+ memset(m.presets, 0, sizeof *m.presets);
+ m.presets[0].name = xstrdup(def);
+ m.presets[0].provider = prov ? xstrdup(prov) : NULL;
+ m.presets[0].model = model ? xstrdup(model) : NULL;
+ m.presets[0].base_url = base_url ? xstrdup(base_url) : NULL;
+ m.presets[0].api_key = key ? xstrdup(key) : NULL;
+ if (mt) {
+ m.presets[0].max_turns = mt;
+ m.presets[0].has_max_turns = true;
+ }
+ if (ctx) {
+ m.presets[0].context_limit = ctx;
+ m.presets[0].has_context_limit = true;
+ }
+ m.preset_count = 1;
+ return m;
+}
+
+static void test_preset_overrides_defaults(void) {
+ /* CLI base_url (with an unparseable-ish provider) lets the preset supply
+ * provider/model/key; every preset field applies over the base. */
+ cli c = mkcli();
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file m = mkmodels("local", "openai", "llm", NULL, "pk", 7, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && strcmp(name, "local") == 0, "preset applied");
+ CHECK(cfg.provider == PROV_OPENAI, "preset provider");
+ CHECK_EQ_STR(cfg.model, "llm", "preset model");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept");
+ CHECK_EQ_STR(cfg.api_key, "pk", "preset api_key");
+ CHECK(cfg.max_turns == 7, "preset max_turns");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_cli_flag_wins(void) {
+ /* CLI --model / --provider are authoritative: the preset must not clobber
+ * them (it may still fill fields the CLI left unset). */
+ cli c = mkcli();
+ c.model = xstrdup("cli-model");
+ c.provider = xstrdup("anthropic");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file m = mkmodels("local", "openai", "preset-model",
+ "http://preset.example/v1", NULL, 0, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name, "preset applied");
+ CHECK(cfg.provider == PROV_ANTHROPIC, "CLI provider wins");
+ CHECK_EQ_STR(cfg.model, "cli-model", "CLI model wins");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "CLI base_url wins");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_errors(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file empty;
+ memset(&empty, 0, sizeof empty);
+ char *name = config_apply_models_preset(&cfg, &empty, &c, &err);
+ CHECK(!name && err && strstr(err, "no `default` preset"),
+ "no default preset error");
+ free(err);
+ err = NULL;
+
+ models_file ghost = mkmodels("ghost", NULL, NULL, NULL, NULL, 0, 0);
+ free(ghost.presets[0].name);
+ ghost.presets[0].name = xstrdup("other");
+ name = config_apply_models_preset(&cfg, &ghost, &c, &err);
+ CHECK(!name && err && strstr(err, "not in presets"),
+ "default not in presets error");
+ free(err);
+ models_file_free(&ghost);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_supplies_api_key(void) {
+ /* Base is valid via base_url but has no key; the preset fills the key
+ * (and the model) for a key-requiring provider. */
+ cli c = mkcli();
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.api_key == NULL, "no key before preset");
+
+ models_file m = mkmodels("cloud", "openai", "cm", NULL, "pk", 0, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name, "preset applied");
+ CHECK(cfg.provider == PROV_OPENAI, "preset provider");
+ CHECK_EQ_STR(cfg.api_key, "pk", "preset supplies api_key");
+ CHECK_EQ_STR(cfg.model, "cm", "preset model");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_context_limit(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ models_file m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && cfg.context_limit == 8000, "preset context_limit applied");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* explicit CLI --context-limit wins over the preset */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ c.context_limit = 16000;
+ c.has_context_limit = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000);
+ name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && cfg.context_limit == 16000, "CLI context_limit wins");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+
+static void test_merge_openai_requires_key(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("openai");
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok without key (preset may supply it)");
+ r = config_require_key_or_base(&cfg, &err);
+ CHECK(r == -1 && err && strstr(err, "no API key"),
+ "openai without key/base_url rejected");
+ free(err);
+ config_free(&cfg);
+ cli_free(&c);
+}
+
+int main(void) {
+ test_provider_parsing();
+ test_cli_parse_basic();
+ test_cli_shell_policy_options();
+ test_cli_parse_rejects_negative_ulong();
+ test_cli_parse_fraction_thresholds();
+ test_merge_cli_values();
+ test_merge_defaults();
+ test_merge_api_key_rules();
+ test_merge_max_turns_and_retries();
+ test_merge_compaction();
+ test_merge_lower_bounds_and_verbose();
+ test_preset_overrides_defaults();
+ test_preset_cli_flag_wins();
+ test_preset_errors();
+ test_preset_supplies_api_key();
+ test_preset_context_limit();
+ test_merge_openai_requires_key();
+ printf("test_config: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}