feat(discover): add user-defined extension→language mappings via config files

Read extra_extensions from ~/.config/codebase-memory-mcp/config.json
(global) and {repo}/.codebase-memory.json (project), merging them with
project config taking precedence. Unknown language values warn and skip.
Missing files are silently ignored (fail-open).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shane McCarron
2026-03-20 17:25:33 -05:00
co-authored by Claude Sonnet 4.6
parent de427776c0
commit 1cdb98319a
7 changed files with 706 additions and 0 deletions
+2
View File
@@ -148,6 +148,7 @@ MCP_SRCS = src/mcp/mcp.c
# Discover module (new)
DISCOVER_SRCS = \
src/discover/language.c \
src/discover/userconfig.c \
src/discover/gitignore.c \
src/discover/discover.c
@@ -259,6 +260,7 @@ TEST_MCP_SRCS = \
TEST_DISCOVER_SRCS = \
tests/test_language.c \
tests/test_userconfig.c \
tests/test_gitignore.c \
tests/test_discover.c
+12
View File
@@ -3,8 +3,11 @@
*
* Maps file extensions and special filenames to CBMLanguage enum values.
* Handles .m disambiguation (Objective-C vs Magma vs MATLAB).
* Consults the process-global user config (set via cbm_set_user_lang_config)
* before the built-in lookup table.
*/
#include "discover/discover.h"
#include "discover/userconfig.h"
#include "cbm.h" // CBMLanguage, CBM_LANG_*
#include <ctype.h>
@@ -354,6 +357,15 @@ CBMLanguage cbm_language_for_extension(const char *ext) {
return CBM_LANG_COUNT;
}
/* Check user-defined overrides first */
const cbm_userconfig_t *ucfg = cbm_get_user_lang_config();
if (ucfg) {
CBMLanguage ulang = cbm_userconfig_lookup(ucfg, ext);
if (ulang != CBM_LANG_COUNT) {
return ulang;
}
}
for (size_t i = 0; i < EXT_TABLE_SIZE; i++) {
if (strcmp(EXT_TABLE[i].ext, ext) == 0) {
return EXT_TABLE[i].language;
+388
View File
@@ -0,0 +1,388 @@
/*
* userconfig.c — User-defined extension→language mappings.
*
* Reads extra_extensions from:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*/
#include "discover/userconfig.h"
#include "foundation/log.h"
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Process-global user config pointer ──────────────────────────── */
static const cbm_userconfig_t *g_userconfig = NULL;
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg) {
g_userconfig = cfg;
}
const cbm_userconfig_t *cbm_get_user_lang_config(void) {
return g_userconfig;
}
/* ── Language name → enum table ──────────────────────────────────── */
/*
* Reverse-mapping from lowercase language name strings to CBMLanguage.
* Covers all names exposed by cbm_language_name() plus common aliases.
*/
typedef struct {
const char *name; /* lowercase */
CBMLanguage lang;
} lang_name_entry_t;
static const lang_name_entry_t LANG_NAME_TABLE[] = {
{"go", CBM_LANG_GO},
{"python", CBM_LANG_PYTHON},
{"javascript", CBM_LANG_JAVASCRIPT},
{"typescript", CBM_LANG_TYPESCRIPT},
{"tsx", CBM_LANG_TSX},
{"rust", CBM_LANG_RUST},
{"java", CBM_LANG_JAVA},
{"c++", CBM_LANG_CPP},
{"cpp", CBM_LANG_CPP},
{"c#", CBM_LANG_CSHARP},
{"csharp", CBM_LANG_CSHARP},
{"php", CBM_LANG_PHP},
{"lua", CBM_LANG_LUA},
{"scala", CBM_LANG_SCALA},
{"kotlin", CBM_LANG_KOTLIN},
{"ruby", CBM_LANG_RUBY},
{"c", CBM_LANG_C},
{"bash", CBM_LANG_BASH},
{"sh", CBM_LANG_BASH},
{"zig", CBM_LANG_ZIG},
{"elixir", CBM_LANG_ELIXIR},
{"haskell", CBM_LANG_HASKELL},
{"ocaml", CBM_LANG_OCAML},
{"objective-c", CBM_LANG_OBJC},
{"objc", CBM_LANG_OBJC},
{"swift", CBM_LANG_SWIFT},
{"dart", CBM_LANG_DART},
{"perl", CBM_LANG_PERL},
{"groovy", CBM_LANG_GROOVY},
{"erlang", CBM_LANG_ERLANG},
{"r", CBM_LANG_R},
{"html", CBM_LANG_HTML},
{"css", CBM_LANG_CSS},
{"scss", CBM_LANG_SCSS},
{"yaml", CBM_LANG_YAML},
{"toml", CBM_LANG_TOML},
{"hcl", CBM_LANG_HCL},
{"terraform", CBM_LANG_HCL},
{"sql", CBM_LANG_SQL},
{"dockerfile", CBM_LANG_DOCKERFILE},
{"clojure", CBM_LANG_CLOJURE},
{"f#", CBM_LANG_FSHARP},
{"fsharp", CBM_LANG_FSHARP},
{"julia", CBM_LANG_JULIA},
{"vimscript", CBM_LANG_VIMSCRIPT},
{"nix", CBM_LANG_NIX},
{"common lisp", CBM_LANG_COMMONLISP},
{"commonlisp", CBM_LANG_COMMONLISP},
{"lisp", CBM_LANG_COMMONLISP},
{"elm", CBM_LANG_ELM},
{"fortran", CBM_LANG_FORTRAN},
{"cuda", CBM_LANG_CUDA},
{"cobol", CBM_LANG_COBOL},
{"verilog", CBM_LANG_VERILOG},
{"emacs lisp", CBM_LANG_EMACSLISP},
{"emacslisp", CBM_LANG_EMACSLISP},
{"json", CBM_LANG_JSON},
{"xml", CBM_LANG_XML},
{"markdown", CBM_LANG_MARKDOWN},
{"makefile", CBM_LANG_MAKEFILE},
{"cmake", CBM_LANG_CMAKE},
{"protobuf", CBM_LANG_PROTOBUF},
{"graphql", CBM_LANG_GRAPHQL},
{"vue", CBM_LANG_VUE},
{"svelte", CBM_LANG_SVELTE},
{"meson", CBM_LANG_MESON},
{"glsl", CBM_LANG_GLSL},
{"ini", CBM_LANG_INI},
{"matlab", CBM_LANG_MATLAB},
{"lean", CBM_LANG_LEAN},
{"form", CBM_LANG_FORM},
{"magma", CBM_LANG_MAGMA},
{"wolfram", CBM_LANG_WOLFRAM},
};
#define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0]))
/*
* Parse a language string (case-insensitive) to a CBMLanguage enum.
* Returns CBM_LANG_COUNT if the string is not recognized.
*/
static CBMLanguage lang_from_string(const char *s) {
if (!s || !s[0]) {
return CBM_LANG_COUNT;
}
/* Build a lowercase copy for comparison */
char lower[64];
size_t i;
for (i = 0; i < sizeof(lower) - 1 && s[i]; i++) {
lower[i] = (char)tolower((unsigned char)s[i]);
}
lower[i] = '\0';
for (size_t j = 0; j < LANG_NAME_TABLE_SIZE; j++) {
if (strcmp(LANG_NAME_TABLE[j].name, lower) == 0) {
return LANG_NAME_TABLE[j].lang;
}
}
return CBM_LANG_COUNT;
}
/* ── Config directory helper ─────────────────────────────────────── */
/*
* Get the XDG config dir for codebase-memory-mcp.
* Writes "<dir>/codebase-memory-mcp" into buf (up to bufsz bytes).
* Uses $XDG_CONFIG_HOME if set, else ~/.config.
*/
static void cbm_app_config_dir(char *buf, size_t bufsz) {
// NOLINT(concurrency-mt-unsafe) — called before worker threads
const char *xdg = getenv("XDG_CONFIG_HOME");
if (xdg && xdg[0]) {
snprintf(buf, bufsz, "%s/codebase-memory-mcp", xdg);
} else {
const char *home = getenv("HOME"); // NOLINT(concurrency-mt-unsafe)
if (!home || !home[0]) {
home = "/tmp";
}
snprintf(buf, bufsz, "%s/.config/codebase-memory-mcp", home);
}
}
/* ── JSON parsing ────────────────────────────────────────────────── */
/*
* Parse extra_extensions from a yyjson object root.
* Appends valid entries to *entries / *count (growing via realloc).
* Project-level entries (from_project=true) are appended after global
* entries so that a later dedup pass can prefer project values.
*
* Returns 0 on success, -1 on alloc failure.
*/
static int parse_extra_extensions(yyjson_val *root, cbm_userext_t **entries, int *count,
const char *source_label) {
if (!yyjson_is_obj(root)) {
cbm_log_warn("userconfig.bad_root", "file", source_label);
return 0;
}
yyjson_val *extra = yyjson_obj_get(root, "extra_extensions");
if (!extra) {
return 0; /* key absent — fine */
}
if (!yyjson_is_obj(extra)) {
cbm_log_warn("userconfig.bad_extra_extensions", "file", source_label);
return 0;
}
yyjson_obj_iter iter;
yyjson_obj_iter_init(extra, &iter);
yyjson_val *key;
while ((key = yyjson_obj_iter_next(&iter)) != NULL) {
yyjson_val *val = yyjson_obj_iter_get_val(key);
const char *ext_str = yyjson_get_str(key);
const char *lang_str = yyjson_get_str(val);
if (!ext_str || !lang_str) {
cbm_log_warn("userconfig.skip_non_string", "file", source_label);
continue;
}
/* Extension must start with '.' */
if (ext_str[0] != '.') {
cbm_log_warn("userconfig.skip_bad_ext", "file", source_label, "ext", ext_str);
continue;
}
CBMLanguage lang = lang_from_string(lang_str);
if (lang == CBM_LANG_COUNT) {
cbm_log_warn("userconfig.unknown_lang", "file", source_label, "lang", lang_str);
continue; /* fail-open: skip unknown languages */
}
/* Grow the array */
cbm_userext_t *tmp =
realloc(*entries, (size_t)(*count + 1) * sizeof(cbm_userext_t));
if (!tmp) {
return -1;
}
*entries = tmp;
char *ext_copy = strdup(ext_str);
if (!ext_copy) {
return -1;
}
(*entries)[*count].ext = ext_copy;
(*entries)[*count].lang = lang;
(*count)++;
}
return 0;
}
/*
* Read a JSON file and parse extra_extensions from it.
* Silently ignores missing files. Logs warnings for corrupt JSON.
* Returns 0 on success (or absent file), -1 on alloc failure.
*/
static int load_config_file(const char *path, cbm_userext_t **entries, int *count) {
FILE *f = fopen(path, "rb");
if (!f) {
return 0; /* file absent — silently ignore */
}
fseek(f, 0, SEEK_END);
long len = ftell(f);
fseek(f, 0, SEEK_SET);
if (len <= 0 || len > 65536) {
fclose(f);
if (len > 65536) {
cbm_log_warn("userconfig.file_too_large", "path", path);
}
return 0;
}
char *buf = malloc((size_t)len + 1);
if (!buf) {
fclose(f);
return -1;
}
size_t nread = fread(buf, 1, (size_t)len, f);
fclose(f);
buf[nread] = '\0';
yyjson_doc *doc = yyjson_read(buf, nread, 0);
free(buf);
if (!doc) {
cbm_log_warn("userconfig.corrupt_json", "path", path);
return 0; /* corrupt JSON — silently ignore (fail-open) */
}
yyjson_val *root = yyjson_doc_get_root(doc);
int rc = parse_extra_extensions(root, entries, count, path);
yyjson_doc_free(doc);
return rc;
}
/* ── Public API ──────────────────────────────────────────────────── */
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) {
cbm_userconfig_t *cfg = calloc(1, sizeof(cbm_userconfig_t));
if (!cfg) {
return NULL;
}
cbm_userext_t *entries = NULL;
int count = 0;
/* ── Step 1: Load global config ── */
char global_dir[1024];
cbm_app_config_dir(global_dir, sizeof(global_dir));
char global_path[1280];
snprintf(global_path, sizeof(global_path), "%s/config.json", global_dir);
if (load_config_file(global_path, &entries, &count) != 0) {
free(cfg);
return NULL;
}
int global_count = count; /* entries[0..global_count) are from global */
/* ── Step 2: Load project config ── */
if (repo_path && repo_path[0]) {
char project_path[1280];
snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path);
if (load_config_file(project_path, &entries, &count) != 0) {
/* Free already-allocated entries */
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
}
/*
* ── Step 3: Dedup — project entries win over global ──
*
* For any extension that appears in both global (indices 0..global_count)
* and project (indices global_count..count), remove the global entry by
* replacing it with the last global entry (order-insensitive dedup).
*/
for (int p = global_count; p < count; p++) {
for (int g = 0; g < global_count; g++) {
if (entries[g].ext && strcmp(entries[g].ext, entries[p].ext) == 0) {
/* Remove global entry: overwrite with last global entry */
free(entries[g].ext);
entries[g] = entries[global_count - 1];
entries[global_count - 1].ext = NULL; /* mark as consumed */
global_count--;
g--; /* re-check this index */
break;
}
}
}
/*
* Compact: remove any NULL-ext slots left by the dedup step.
* (Those are the consumed "last global" entries.)
*/
int write_idx = 0;
for (int i = 0; i < count; i++) {
if (entries[i].ext != NULL) {
entries[write_idx++] = entries[i];
}
}
count = write_idx;
cfg->entries = entries;
cfg->count = count;
return cfg;
}
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext) {
if (!cfg || !ext || !ext[0]) {
return CBM_LANG_COUNT;
}
for (int i = 0; i < cfg->count; i++) {
if (cfg->entries[i].ext && strcmp(cfg->entries[i].ext, ext) == 0) {
return cfg->entries[i].lang;
}
}
return CBM_LANG_COUNT;
}
void cbm_userconfig_free(cbm_userconfig_t *cfg) {
if (!cfg) {
return;
}
for (int i = 0; i < cfg->count; i++) {
free(cfg->entries[i].ext);
}
free(cfg->entries);
free(cfg);
}
+72
View File
@@ -0,0 +1,72 @@
/*
* userconfig.h — User-defined file extension → language mappings.
*
* Reads extra_extensions from two optional JSON config files:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*
* Format:
* {"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
*
* The language string matching is case-insensitive.
*/
#ifndef CBM_USERCONFIG_H
#define CBM_USERCONFIG_H
#include "cbm.h" /* CBMLanguage */
/* ── Types ──────────────────────────────────────────────────────── */
typedef struct {
char *ext; /* file extension including dot, e.g. ".blade.php" */
CBMLanguage lang; /* resolved language enum */
} cbm_userext_t;
typedef struct {
cbm_userext_t *entries; /* heap-allocated array */
int count; /* number of entries */
} cbm_userconfig_t;
/* ── API ────────────────────────────────────────────────────────── */
/*
* Load user config from global + project files, merge (project wins).
* repo_path: absolute path to the repository root (for project config).
* Returns a heap-allocated cbm_userconfig_t (caller must free via
* cbm_userconfig_free). Returns NULL only on allocation failure.
* Missing config files are silently ignored.
*/
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path);
/*
* Look up a file extension in the user config.
* ext: extension including dot, e.g. ".blade.php"
* Returns the mapped CBMLanguage, or CBM_LANG_COUNT if not found.
*/
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext);
/* Free a cbm_userconfig_t returned by cbm_userconfig_load. NULL-safe. */
void cbm_userconfig_free(cbm_userconfig_t *cfg);
/* ── Integration hook ───────────────────────────────────────────── */
/*
* Set the process-global user config that cbm_language_for_extension()
* will consult before the built-in table.
* cfg may be NULL to clear the override.
* Not thread-safe — call before spawning worker threads.
*/
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg);
/*
* Get the currently active process-global user config.
* Returns NULL if none has been set.
* Called internally by cbm_language_for_extension().
*/
const cbm_userconfig_t *cbm_get_user_lang_config(void);
#endif /* CBM_USERCONFIG_H */
+12
View File
@@ -17,6 +17,7 @@
#include "graph_buffer/graph_buffer.h"
#include "store/store.h"
#include "discover/discover.h"
#include "discover/userconfig.h"
#include "foundation/platform.h"
#include "foundation/compat_fs.h"
#include "foundation/log.h"
@@ -44,6 +45,9 @@ struct cbm_pipeline {
/* Indexing state (set during run) */
cbm_gbuf_t *gbuf;
cbm_registry_t *registry;
/* User-defined extension overrides (loaded once per run) */
cbm_userconfig_t *userconfig;
};
/* ── Timing helper ──────────────────────────────────────────────── */
@@ -303,6 +307,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
struct timespec t0;
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
/* Load user-defined extension overrides (fail-open: NULL on error) */
p->userconfig = cbm_userconfig_load(p->repo_path);
cbm_set_user_lang_config(p->userconfig);
/* Phase 1: Discover files */
cbm_discover_opts_t opts = {
.mode = p->mode,
@@ -759,5 +767,9 @@ cleanup:
p->gbuf = NULL;
cbm_registry_free(p->registry);
p->registry = NULL;
/* Clear and free user extension config */
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(p->userconfig);
p->userconfig = NULL;
return rc;
}
+2
View File
@@ -26,6 +26,7 @@ extern void suite_store_search(void);
extern void suite_cypher(void);
extern void suite_mcp(void);
extern void suite_language(void);
extern void suite_userconfig(void);
extern void suite_gitignore(void);
extern void suite_discover(void);
extern void suite_graph_buffer(void);
@@ -80,6 +81,7 @@ int main(void) {
/* Discover (M2) */
RUN_SUITE(language);
RUN_SUITE(userconfig);
RUN_SUITE(gitignore);
RUN_SUITE(discover);
+218
View File
@@ -0,0 +1,218 @@
/*
* test_userconfig.c — Tests for user-defined extension→language mappings.
*
* Tests cbm_userconfig_load(), cbm_userconfig_lookup(), and the
* cbm_set_user_lang_config() / cbm_language_for_extension() integration.
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "test_framework.h"
#include "discover/discover.h"
#include "discover/userconfig.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Helpers ─────────────────────────────────────────────────────── */
/* Write a JSON file to path. Returns 0 on success. */
static int write_json(const char *path, const char *json) {
FILE *f = fopen(path, "w");
if (!f) {
return -1;
}
fputs(json, f);
fclose(f);
return 0;
}
/* ── Tests: project config ───────────────────────────────────────── */
TEST(userconfig_project_basic) {
/* Write a .codebase-memory.json in a temp dir */
char dir[256];
snprintf(dir, sizeof(dir), "%s/uctest_proj_basic", cbm_tmpdir());
cbm_mkdir_p(dir, 0755); /* from compat_fs.h via compat.h */
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
ASSERT_EQ(
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".blade.php"), CBM_LANG_PHP);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".go"), CBM_LANG_COUNT); /* not in user config */
cbm_userconfig_free(cfg);
remove(proj);
PASS();
}
/* ── Tests: global config ────────────────────────────────────────── */
TEST(userconfig_global_via_env) {
/* Point XDG_CONFIG_HOME to a temp dir */
char xdg_dir[256];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/uctest_global_xdg", cbm_tmpdir());
char app_dir[512];
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", xdg_dir);
cbm_mkdir_p(app_dir, 0755);
char global_path[768];
snprintf(global_path, sizeof(global_path), "%s/config.json", app_dir);
ASSERT_EQ(
write_json(global_path, "{\"extra_extensions\":{\".twig\":\"html\"}}"),
0);
/* Set env var, load, restore */
setenv("XDG_CONFIG_HOME", xdg_dir, 1);
cbm_userconfig_t *cfg = cbm_userconfig_load(NULL); /* no project dir */
unsetenv("XDG_CONFIG_HOME");
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".twig"), CBM_LANG_HTML);
cbm_userconfig_free(cfg);
remove(global_path);
PASS();
}
/* ── Tests: project wins over global ────────────────────────────── */
TEST(userconfig_project_wins_over_global) {
/* Global says .xyz → python; project says .xyz → rust */
char xdg_dir[256];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/uctest_priority_xdg", cbm_tmpdir());
char app_dir[512];
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", xdg_dir);
cbm_mkdir_p(app_dir, 0755);
char global_path[768];
snprintf(global_path, sizeof(global_path), "%s/config.json", app_dir);
ASSERT_EQ(
write_json(global_path, "{\"extra_extensions\":{\".xyz\":\"python\"}}"),
0);
char proj_dir[256];
snprintf(proj_dir, sizeof(proj_dir), "%s/uctest_priority_proj", cbm_tmpdir());
cbm_mkdir_p(proj_dir, 0755);
char proj_path[512];
snprintf(proj_path, sizeof(proj_path), "%s/.codebase-memory.json", proj_dir);
ASSERT_EQ(
write_json(proj_path, "{\"extra_extensions\":{\".xyz\":\"rust\"}}"),
0);
setenv("XDG_CONFIG_HOME", xdg_dir, 1);
cbm_userconfig_t *cfg = cbm_userconfig_load(proj_dir);
unsetenv("XDG_CONFIG_HOME");
ASSERT_NOT_NULL(cfg);
/* Project definition (rust) must win */
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".xyz"), CBM_LANG_RUST);
cbm_userconfig_free(cfg);
remove(global_path);
remove(proj_path);
PASS();
}
/* ── Tests: unknown language values are skipped ──────────────────── */
TEST(userconfig_unknown_lang_skipped) {
char dir[256];
snprintf(dir, sizeof(dir), "%s/uctest_unknown_lang", cbm_tmpdir());
cbm_mkdir_p(dir, 0755);
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
/* "klingon" is not a valid language; ".wasm" should be silently skipped */
ASSERT_EQ(
write_json(proj,
"{\"extra_extensions\":{\".wasm\":\"klingon\",\".mjs\":\"javascript\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
ASSERT_NOT_NULL(cfg);
/* .wasm with unknown lang → not in config */
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".wasm"), CBM_LANG_COUNT);
/* .mjs with valid lang → present */
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
cbm_userconfig_free(cfg);
remove(proj);
PASS();
}
/* ── Tests: missing files are silently ignored ───────────────────── */
TEST(userconfig_missing_files_ok) {
/* Point to a non-existent repo dir */
cbm_userconfig_t *cfg = cbm_userconfig_load("/tmp/__nonexistent_repo_12345__");
ASSERT_NOT_NULL(cfg); /* must not return NULL — just empty */
ASSERT_EQ(cfg->count, 0);
cbm_userconfig_free(cfg);
PASS();
}
/* ── Tests: integration with cbm_language_for_extension ─────────── */
TEST(userconfig_integration_override) {
/* Verify that setting the global config makes cbm_language_for_extension
* respect the override. We map ".blade.php" → PHP, which is not in the
* built-in table. */
char dir[256];
snprintf(dir, sizeof(dir), "%s/uctest_integ", cbm_tmpdir());
cbm_mkdir_p(dir, 0755);
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
ASSERT_EQ(
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
ASSERT_NOT_NULL(cfg);
/* Before setting, .blade.php is unknown */
ASSERT_EQ(cbm_language_for_extension(".blade.php"), CBM_LANG_COUNT);
cbm_set_user_lang_config(cfg);
/* After setting, .blade.php → PHP */
ASSERT_EQ(cbm_language_for_extension(".blade.php"), CBM_LANG_PHP);
/* Built-in extensions still work */
ASSERT_EQ(cbm_language_for_extension(".go"), CBM_LANG_GO);
/* Clean up global state */
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(cfg);
remove(proj);
PASS();
}
/* ── Tests: free is NULL-safe ────────────────────────────────────── */
TEST(userconfig_free_null) {
cbm_userconfig_free(NULL); /* must not crash */
PASS();
}
/* ── Suite ──────────────────────────────────────────────────────── */
SUITE(userconfig) {
RUN_TEST(userconfig_project_basic);
RUN_TEST(userconfig_global_via_env);
RUN_TEST(userconfig_project_wins_over_global);
RUN_TEST(userconfig_unknown_lang_skipped);
RUN_TEST(userconfig_missing_files_ok);
RUN_TEST(userconfig_integration_override);
RUN_TEST(userconfig_free_null);
}