/* test_config.c - 配置默认值 / 环境变量 / 配置文件覆盖 */ #include "config.h" #include "util.h" #include "i18n.h" #include #include #include #include #include #include static int failures = 0; static int checks = 0; #define CHECK(cond, ...) \ do { \ checks++; \ if (!(cond)) { \ failures++; \ printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ printf(__VA_ARGS__); \ printf("\n"); \ } \ } while (0) static void unset_all(void) { const char *vars[] = { "LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", "LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM", "LIGENT_TEMPERATURE", "LIGENT_MAX_TOKENS", "LIGENT_TIMEOUT", "LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS", "OPENAI_BASE_URL", "LIGENT_CONFIG", }; for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) unsetenv(vars[i]); } static void test_defaults(void) { puts("默认值"); unset_all(); LigentConfig c; config_defaults(&c); CHECK(strcmp(c.model, "deepseek-flash") == 0, "默认模型应为 deepseek-flash, 实际 %s", c.model); CHECK(strcmp(c.base_url, "https://api.deepseek.com/v1") == 0, "默认 base_url 应为 DeepSeek, 实际 %s", c.base_url); CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空"); CHECK(c.stream == 1, "默认应开启流式"); CHECK(c.use_tools == 1, "默认应启用工具"); /* 默认不限往返次数: 0 (或负数)= 不限, 只有正数才是上限 */ CHECK(c.max_steps == 0, "默认应不限往返次数, 实际 %d", c.max_steps); config_free(&c); } static void test_env_key(void) { puts("环境变量取 key"); unset_all(); LigentConfig c; config_defaults(&c); /* 没有任何 key */ config_apply_env(&c); CHECK(c.api_key[0] == '\0', "无环境变量时 key 应为空"); /* 只设 DEEPSEEK_API_KEY: 默认就应读它 */ setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-deepseek") == 0, "应读 $DEEPSEEK_API_KEY, 实际 [%s]", c.api_key); /* LIGENT_API_KEY 作为显式覆盖优先 */ setenv("LIGENT_API_KEY", "sk-explicit", 1); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-explicit") == 0, "LIGENT_API_KEY 应覆盖, 实际 [%s]", c.api_key); /* OPENAI_API_KEY 只在端点确实是 OpenAI 时才兜底 —— * 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */ unset_all(); setenv("OPENAI_API_KEY", "sk-openai", 1); /* 模拟"配置文件没写 api_key": 必须先清掉上一步留下的值 */ free(c.api_key); c.api_key = xstrdup(""); config_apply_env(&c); CHECK(c.api_key[0] == '\0', "DeepSeek 端点不应兜底到 OPENAI_API_KEY"); free(c.base_url); c.base_url = xstrdup("https://api.openai.com/v1"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-openai") == 0, "OpenAI 端点应兜底到 OPENAI_API_KEY, 实际 %s", c.api_key); unset_all(); config_free(&c); } static void test_env_others(void) { puts("环境变量其他项"); unset_all(); LigentConfig c; config_defaults(&c); setenv("LIGENT_MODEL", "my-model", 1); setenv("LIGENT_MAX_STEPS", "7", 1); setenv("LIGENT_STREAM", "0", 1); setenv("LIGENT_TOOLS", "false", 1); setenv("LIGENT_THINKING", "off", 1); setenv("LIGENT_REASONING_EFFORT", "max", 1); config_apply_env(&c); CHECK(strcmp(c.model, "my-model") == 0, "LIGENT_MODEL 应生效"); CHECK(c.max_steps == 7, "LIGENT_MAX_STEPS 应生效"); CHECK(c.stream == 0, "LIGENT_STREAM=0 应关闭流式"); CHECK(c.use_tools == 0, "LIGENT_TOOLS=false 应关闭工具"); CHECK(c.thinking == THINKING_OFF, "LIGENT_THINKING=off 应关闭思考"); CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "max") == 0, "LIGENT_REASONING_EFFORT=max 应生效"); unset_all(); config_free(&c); /* 环境变量是临时开关: 写错了应当被忽略, 而不是把程序拦下来 */ config_defaults(&c); setenv("LIGENT_THINKING", "随便写的", 1); setenv("LIGENT_REASONING_EFFORT", "超高", 1); config_apply_env(&c); CHECK(c.thinking == THINKING_AUTO, "无法识别的 LIGENT_THINKING 应被忽略"); CHECK(c.reasoning_effort == NULL, "无法识别的级别应被忽略"); unset_all(); config_free(&c); } static void test_thinking_parse(void) { puts("思考模式 / 级别解析"); int t = 123; CHECK(config_parse_thinking("auto", &t) == 0 && t == THINKING_AUTO, "auto"); CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO, "空串 = auto"); CHECK(config_parse_thinking("on", &t) == 0 && t == THINKING_ON, "on"); CHECK(config_parse_thinking("true", &t) == 0 && t == THINKING_ON, "true"); CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON, "enabled"); CHECK(config_parse_thinking("off", &t) == 0 && t == THINKING_OFF, "off"); CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF, "false"); CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF, "disabled"); CHECK(config_parse_thinking("maybe", &t) != 0, "无法识别的值应报错"); CHECK(config_parse_thinking(NULL, &t) != 0, "NULL 应报错"); static const char *const good[] = { "minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL }; for (size_t i = 0; good[i]; i++) CHECK(config_effort_valid(good[i]), "%s 应是合法级别", good[i]); CHECK(!config_effort_valid("huge"), "huge 不是合法级别"); CHECK(!config_effort_valid(""), "空串不是合法级别"); CHECK(!config_effort_valid(NULL), "NULL 不是合法级别"); CHECK(strstr(config_effort_list(), "ultra") != NULL, "展示串应含全部级别"); CHECK(config_thinking_name(THINKING_AUTO) != NULL, "THINKING_AUTO 有说明"); CHECK(config_thinking_name(THINKING_ON) != NULL, "THINKING_ON 有说明"); CHECK(config_thinking_name(THINKING_OFF) != NULL, "THINKING_OFF 有说明"); } static void test_file_thinking(void) { puts("配置文件里的 thinking / reasoning_effort"); char err[256]; LigentConfig c; /* 布尔写法 */ char t1[] = "/tmp/ligent_think_XXXXXX"; int fd = mkstemp(t1); if (fd < 0) { CHECK(0, "mkstemp 失败"); return; } const char *j1 = "{\"thinking\":false,\"reasoning_effort\":\"medium\"}"; ssize_t w = write(fd, j1, strlen(j1)); close(fd); CHECK(w == (ssize_t)strlen(j1), "写临时配置失败"); config_defaults(&c); CHECK(config_load_file(&c, t1, err, sizeof err) == 0, "加载失败: %s", err); CHECK(c.thinking == THINKING_OFF, "thinking:false 应为关闭"); CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "medium") == 0, "reasoning_effort 应被读入"); config_free(&c); unlink(t1); /* 字符串写法 */ char t2[] = "/tmp/ligent_think_XXXXXX"; fd = mkstemp(t2); if (fd >= 0) { const char *j2 = "{\"thinking\":\"on\"}"; w = write(fd, j2, strlen(j2)); close(fd); CHECK(w == (ssize_t)strlen(j2), "写临时配置失败"); config_defaults(&c); CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", err); CHECK(c.thinking == THINKING_ON, "thinking:\"on\" 应为开启"); config_free(&c); unlink(t2); } /* 写错了必须报错, 不能静默忽略 —— 配置文件是长期存在的, 静默会让人困惑 */ char t3[] = "/tmp/ligent_think_XXXXXX"; fd = mkstemp(t3); if (fd >= 0) { const char *j3 = "{\"thinking\":\"大概吧\"}"; w = write(fd, j3, strlen(j3)); close(fd); CHECK(w == (ssize_t)strlen(j3), "写临时配置失败"); config_defaults(&c); CHECK(config_load_file(&c, t3, err, sizeof err) != 0, "非法的 thinking 应加载失败"); config_free(&c); unlink(t3); } char t4[] = "/tmp/ligent_think_XXXXXX"; fd = mkstemp(t4); if (fd >= 0) { const char *j4 = "{\"reasoning_effort\":\"超级高\"}"; w = write(fd, j4, strlen(j4)); close(fd); CHECK(w == (ssize_t)strlen(j4), "写临时配置失败"); config_defaults(&c); CHECK(config_load_file(&c, t4, err, sizeof err) != 0, "非法的 reasoning_effort 应加载失败"); config_free(&c); unlink(t4); } } static void test_file_override(void) { puts("配置文件覆盖 + ${VAR} 展开"); unset_all(); setenv("MY_SECRET", "sk-from-env", 1); char tmpl[] = "/tmp/ligent_cfg_XXXXXX"; int fd = mkstemp(tmpl); CHECK(fd >= 0, "mkstemp 失败"); if (fd < 0) return; const char *json = "{\"base_url\":\"http://localhost:11434/v1\"," "\"api_key\":\"${MY_SECRET}\"," "\"model\":\"qwen2.5:14b\"," "\"temperature\":0.3," "\"max_tokens\":4096," "\"max_steps\":9," "\"stream\":false," "\"tools\":false}"; ssize_t w = write(fd, json, strlen(json)); close(fd); CHECK(w == (ssize_t)strlen(json), "写临时配置失败"); LigentConfig c; config_defaults(&c); char err[256]; int rc = config_load_file(&c, tmpl, err, sizeof err); CHECK(rc == 0, "加载配置失败: %s", err); CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, "base_url 覆盖"); CHECK(strcmp(c.model, "qwen2.5:14b") == 0, "model 覆盖"); CHECK(strcmp(c.api_key, "sk-from-env") == 0, "${MY_SECRET} 应被展开, 实际 [%s]", c.api_key); CHECK(c.temperature > 0.29 && c.temperature < 0.31, "temperature 覆盖"); CHECK(c.max_tokens == 4096, "max_tokens 覆盖"); CHECK(c.max_steps == 9, "max_steps 覆盖"); CHECK(c.stream == 0, "stream 覆盖"); CHECK(c.use_tools == 0, "tools 覆盖"); CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径"); /* 坏配置应报错而不是静默通过. * * 解析器的报错跟着界面语言走 (json_parse_ui), 所以下面这段断语先把语言 * 钉住, 再顺手把英文那份也验一遍 —— 位置信息两种语言都得有. */ char tmpl2[] = "/tmp/ligent_bad_XXXXXX"; int fd2 = mkstemp(tmpl2); if (fd2 >= 0) { const char *bad = "{ not json "; ssize_t w2 = write(fd2, bad, strlen(bad)); (void)w2; close(fd2); LigentConfig c2; i18n_set_lang(LIGENT_LANG_ZH); config_defaults(&c2); CHECK(config_load_file(&c2, tmpl2, err, sizeof err) != 0, "非法 JSON 应加载失败"); CHECK(err[0] != '\0', "应给出错误信息"); /* 报错必须带上文件路径与出错位置, 否则用户无从下手 */ CHECK(strstr(err, tmpl2) != NULL, "错误信息应含文件路径: %s", err); CHECK(strstr(err, "行") != NULL, "错误信息应含出错位置: %s", err); config_free(&c2); LigentConfig c3; i18n_set_lang(LIGENT_LANG_EN); config_defaults(&c3); CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 && strstr(err, "line") != NULL, "英文界面下报错也带位置 (且是英文): %s", err); config_free(&c3); i18n_set_lang(LIGENT_LANG_ZH); unlink(tmpl2); } config_free(&c); unlink(tmpl); unset_all(); } /* 配置只认二进制所在目录: 不做用户级、系统级, 也不做当前目录查找 */ static void test_binary_dir_lookup(void) { puts("配置位置 = 二进制所在目录"); char err[256]; char *dir = config_binary_dir(NULL); char *canon = config_default_path(NULL); CHECK(dir && *dir, "应能解析出二进制所在目录"); CHECK(canon && str_ends_with(canon, "/ligent.json"), "规范路径应以 /ligent.json 结尾: %s", canon ? canon : "(null)"); CHECK(canon && dir && str_starts_with(canon, dir), "规范路径应位于二进制目录下: %s / %s", canon, dir); /* 当前目录放一份 ligent.json, 它绝不能被加载 */ char tmpl[] = "/tmp/ligent_cwd_XXXXXX"; char *tmp = mkdtemp(tmpl); if (!tmp) { free(dir); free(canon); return; } char *fake = xasprintf("%s/ligent.json", tmp); FILE *f = fopen(fake, "w"); if (f) { fputs("{\"model\":\"FROM-CWD-ONLY\"}", f); fclose(f); } char *saved = getcwd(NULL, 0); int loaded_from_cwd = 0; char *got_dir = NULL; if (chdir(tmp) == 0) { LigentConfig c; config_init(&c, NULL, NULL, err, sizeof err); loaded_from_cwd = (c.model && strcmp(c.model, "FROM-CWD-ONLY") == 0); got_dir = c.config_dir ? xstrdup(c.config_dir) : NULL; config_free(&c); if (saved) chdir(saved); } CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json"); CHECK(got_dir && dir && strcmp(got_dir, dir) == 0, "config_dir 应是二进制目录, 实际 %s", got_dir ? got_dir : "(null)"); free(got_dir); free(saved); unlink(fake); /* 先删文件再释放路径字符串 */ free(fake); rmdir(tmp); free(dir); free(canon); } /* 写一个临时配置文件并加载 */ static int load_temp_config(LigentConfig *c, const char *json) { char tmpl[] = "/tmp/ligent_prec_XXXXXX"; int fd = mkstemp(tmpl); if (fd < 0) return -1; ssize_t w = write(fd, json, strlen(json)); close(fd); if (w != (ssize_t)strlen(json)) { unlink(tmpl); return -1; } char err[256]; int rc = config_load_file(c, tmpl, err, sizeof err); unlink(tmpl); return rc; } /* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥. * 这正是用户踩到的坑: 配置写了 OpenRouter 的 key, * shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */ static void test_env_key_precedence(void) { puts("api_key 优先级: 厂商环境变量不得越权"); unset_all(); setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1); /* 1) 配置文件写了 key => 它就是最终值, 环境变量顶不掉 */ LigentConfig c; config_defaults(&c); CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"," "\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0, "配置文件里的 key 不应被残留环境变量顶掉, 实际 %s", c.api_key); config_free(&c); /* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */ config_defaults(&c); CHECK(load_temp_config(&c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0, "DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底, 实际 %s", c.api_key); config_free(&c); /* 3) 配置文件没写 key + 端点是别家 => 不猜, 宁可空着让用户看到明确提示 */ config_defaults(&c); CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(c.api_key[0] == '\0', "base_url 指向别家时不应拿 DeepSeek 的密钥去猜, 实际 %s", c.api_key); config_free(&c); /* 4) LIGENT_API_KEY 是显式覆盖, 任何时候都最高 */ setenv("LIGENT_API_KEY", "sk-explicit", 1); config_defaults(&c); CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"," "\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-explicit") == 0, "LIGENT_API_KEY 应最高优先, 实际 %s", c.api_key); config_free(&c); unset_all(); } /* 软链接: 配置目录要跟着"用户实际调用那个文件"走. * 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录, * 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */ static void test_symlink_dir(void) { puts("软链接: 配置目录跟着软链接走"); char self[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1); if (n <= 0) { puts(" (没有 /proc/self/exe, 跳过)"); return; } self[n] = '\0'; char tmpl[] = "/tmp/ligent_link_XXXXXX"; char *dir = mkdtemp(tmpl); if (!dir) { puts(" (mkdtemp 失败, 跳过)"); return; } char *link = xasprintf("%s/ligent", dir); if (symlink(self, link) != 0) { puts(" (symlink 失败, 跳过)"); free(link); rmdir(dir); return; } /* 核心: 指向软链接时取软链接所在目录, 而不是源文件所在目录 */ char *got = config_binary_dir(link); CHECK(got && strcmp(got, dir) == 0, "应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)"); free(got); /* 路径里的 . 应被规范化, 但最后一段仍不解析 */ char *dotted = xasprintf("%s/./ligent", dir); got = config_binary_dir(dotted); CHECK(got && strcmp(got, dir) == 0, "带 . 的路径应规范化, 实际 %s", got ? got : "(null)"); free(got); free(dotted); /* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */ char *saved = getenv("PATH") ? xstrdup(getenv("PATH")) : NULL; char *newpath = xasprintf("%s:%s", dir, saved ? saved : ""); setenv("PATH", newpath, 1); got = config_binary_dir("ligent"); CHECK(got && strcmp(got, dir) == 0, "$PATH 找到软链接时应取它所在目录, 实际 %s", got ? got : "(null)"); free(got); if (saved) setenv("PATH", saved, 1); else unsetenv("PATH"); free(saved); free(newpath); /* 直接用真实文件时, 取的还是真实目录 */ char *selfdir = xstrdup(self); char *slash = strrchr(selfdir, '/'); if (slash) *slash = '\0'; got = config_binary_dir(self); CHECK(got && strcmp(got, selfdir) == 0, "真实文件应取真实目录 %s, 实际 %s", selfdir, got ? got : "(null)"); free(got); free(selfdir); unlink(link); free(link); rmdir(dir); } int main(void) { test_defaults(); test_env_key(); test_env_others(); test_thinking_parse(); test_file_thinking(); test_file_override(); test_binary_dir_lookup(); test_env_key_precedence(); test_symlink_dir(); printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; }