diff --git a/README.md b/README.md index ab03e9f4..e8962235 100644 --- a/README.md +++ b/README.md @@ -443,6 +443,7 @@ codebase-memory-mcp config reset auto_index # reset to default | `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. | | `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable periodic diagnostics output to `/tmp/cbm-diagnostics-.json`. | | `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. | +| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Logs go to stderr; stdout is reserved for MCP JSON-RPC. | | `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. | ```bash diff --git a/src/foundation/log.c b/src/foundation/log.c index b4daa229..a036a86b 100644 --- a/src/foundation/log.c +++ b/src/foundation/log.c @@ -7,10 +7,49 @@ #include #include #include +#include +#include static CBMLogLevel g_log_level = CBM_LOG_INFO; static cbm_log_sink_fn g_log_sink = NULL; +void cbm_log_init_from_env(void) { + /* Read CBM_LOG_LEVEL — use getenv() directly: this runs before any threads + * are created, so there is no concurrent setenv() race to worry about. */ + const char *val = getenv("CBM_LOG_LEVEL"); + if (!val || val[0] == '\0') { + return; + } + + /* Case-insensitive comparison via tolower on first char + strcmp */ + char lo[16]; + size_t i = 0; + while (i < sizeof(lo) - 1 && val[i] != '\0') { + char c = val[i]; + if (c >= 'A' && c <= 'Z') { + c = (char)(c + ('a' - 'A')); + } + lo[i] = c; + i++; + } + lo[i] = '\0'; + + if (strcmp(lo, "debug") == 0) { cbm_log_set_level(CBM_LOG_DEBUG); } + else if (strcmp(lo, "info") == 0) { cbm_log_set_level(CBM_LOG_INFO); } + else if (strcmp(lo, "warn") == 0) { cbm_log_set_level(CBM_LOG_WARN); } + else if (strcmp(lo, "error") == 0) { cbm_log_set_level(CBM_LOG_ERROR); } + else if (strcmp(lo, "none") == 0) { cbm_log_set_level(CBM_LOG_NONE); } + else { + /* Try numeric: 0=debug 1=info 2=warn 3=error 4=none */ + char *end; + long n = strtol(val, &end, 10); + if (end != val && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) { + cbm_log_set_level((CBMLogLevel)n); + } + /* Unknown value: leave level unchanged (fail-open). */ + } +} + void cbm_log_set_sink(cbm_log_sink_fn fn) { g_log_sink = fn; } diff --git a/src/foundation/log.h b/src/foundation/log.h index e61eb013..32c79e86 100644 --- a/src/foundation/log.h +++ b/src/foundation/log.h @@ -5,7 +5,7 @@ * - All output goes to stderr (stdout is reserved for MCP JSON-RPC) * - Structured format: "level=info msg=pass.timing pass=defs elapsed_ms=42" * - Levels: DEBUG, INFO, WARN, ERROR - * - Level filtering at compile time (CBM_LOG_MIN_LEVEL) and runtime + * - Level filtering at runtime via CBM_LOG_LEVEL env var, or cbm_log_set_level() * - Thread-safe (each fprintf is atomic on POSIX for lines < PIPE_BUF) */ #ifndef CBM_LOG_H @@ -21,12 +21,17 @@ typedef enum { CBM_LOG_NONE = 4 /* disable all logging */ } CBMLogLevel; +/* Initialise log level from the CBM_LOG_LEVEL environment variable. + * Accepted values (case-insensitive): debug, info, warn, error, none. + * Unknown values are silently ignored and the level stays at its current + * value. Call once at startup, before the first log statement. */ +void cbm_log_init_from_env(void); + /* Set minimum log level (default: INFO). */ void cbm_log_set_level(CBMLogLevel level); /* Get current log level. */ CBMLogLevel cbm_log_get_level(void); - /* Core logging function. msg is a short semantic tag. * Variadic args are key-value pairs: (const char *key, const char *value)... * Terminated by NULL key. diff --git a/src/main.c b/src/main.c index d413d9dd..dc3578c2 100644 --- a/src/main.c +++ b/src/main.c @@ -340,6 +340,7 @@ static void setup_signal_handlers(void) { int main(int argc, char **argv) { cbm_profile_init(); /* reads CBM_PROFILE env var, gates all prof macros */ + cbm_log_init_from_env(); /* apply CBM_LOG_LEVEL before any output */ int subcmd = handle_subcommand(argc, argv); if (subcmd >= 0) { return subcmd; diff --git a/tests/test_log.c b/tests/test_log.c index 4d55a3b6..de5583e3 100644 --- a/tests/test_log.c +++ b/tests/test_log.c @@ -112,6 +112,103 @@ TEST(log_int_helper) { PASS(); } +TEST(log_level_from_env_valid) { + /* Valid level strings set the expected level */ + cbm_setenv("CBM_LOG_LEVEL", "error", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_ERROR); + + cbm_setenv("CBM_LOG_LEVEL", "warn", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN); + + cbm_setenv("CBM_LOG_LEVEL", "debug", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_DEBUG); + + cbm_setenv("CBM_LOG_LEVEL", "none", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_NONE); + + cbm_setenv("CBM_LOG_LEVEL", "info", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO); + + cbm_unsetenv("CBM_LOG_LEVEL"); + cbm_log_set_level(CBM_LOG_INFO); /* restore */ + PASS(); +} + +TEST(log_level_from_env_case_insensitive) { + /* Level strings are matched case-insensitively */ + cbm_setenv("CBM_LOG_LEVEL", "ERROR", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_ERROR); + + cbm_setenv("CBM_LOG_LEVEL", "Warn", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN); + + cbm_unsetenv("CBM_LOG_LEVEL"); + cbm_log_set_level(CBM_LOG_INFO); /* restore */ + PASS(); +} + +TEST(log_level_from_env_unknown_ignored) { + /* Unknown values leave the level unchanged */ + cbm_log_set_level(CBM_LOG_INFO); + cbm_setenv("CBM_LOG_LEVEL", "verbose", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO); + + cbm_unsetenv("CBM_LOG_LEVEL"); + PASS(); +} + +TEST(log_level_from_env_unset_ignored) { + /* Missing env var leaves the level unchanged */ + cbm_unsetenv("CBM_LOG_LEVEL"); + cbm_log_set_level(CBM_LOG_WARN); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN); + + cbm_log_set_level(CBM_LOG_INFO); /* restore */ + PASS(); +} + +TEST(log_level_from_env_numeric) { + /* Numeric values mirror the CBMLogLevel enum: 0=debug 1=info 2=warn 3=error 4=none */ + cbm_setenv("CBM_LOG_LEVEL", "0", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_DEBUG); + + cbm_setenv("CBM_LOG_LEVEL", "1", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO); + + cbm_setenv("CBM_LOG_LEVEL", "2", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN); + + cbm_setenv("CBM_LOG_LEVEL", "3", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_ERROR); + + cbm_setenv("CBM_LOG_LEVEL", "4", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_NONE); + + /* Out-of-range numeric: leave level unchanged */ + cbm_log_set_level(CBM_LOG_INFO); + cbm_setenv("CBM_LOG_LEVEL", "5", 1); + cbm_log_init_from_env(); + ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO); + + cbm_unsetenv("CBM_LOG_LEVEL"); + cbm_log_set_level(CBM_LOG_INFO); /* restore */ + PASS(); +} + SUITE(log) { RUN_TEST(log_level_default); RUN_TEST(log_level_set); @@ -119,4 +216,9 @@ SUITE(log) { RUN_TEST(log_filtered_by_level); RUN_TEST(log_error_output); RUN_TEST(log_int_helper); + RUN_TEST(log_level_from_env_valid); + RUN_TEST(log_level_from_env_case_insensitive); + RUN_TEST(log_level_from_env_unknown_ignored); + RUN_TEST(log_level_from_env_unset_ignored); + RUN_TEST(log_level_from_env_numeric); }