diff options
Diffstat (limited to 'tests/test_config.cpp')
| -rw-r--r-- | tests/test_config.cpp | 654 |
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; +} |