#include "config.h" #include #include #include #include #include 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; }