## Guard test for the .env loader (the module the bot uses to read its ## settings from a `.env` file). ## ## NO battle, NO Java, NO server. Run with: ## nim c -r --path:common_libs common_libs/tests/test_env_dotenv.nim ## ## It pins the pieces that must not silently drift: ## * the parser (comments, `export `, quotes, empty value, CRLF, and a ## malformed line that MUST raise with the file+line); ## * resolution precedence (flag > TR_ENV_FILE > ./.env > .env next to the ## executable > none); ## * the FILE WINS over the real environment, and the conflict is reported; ## * a junky value (unrecognised) is WARNED ABOUT at load time instead of ## silently becoming the reader's default (j139); ## * an explicitly requested missing file raises (the caller then exits ## nonzero), while a missing DEFAULT file is a silent no-op. import std/[os, strutils] import "../../ModularBot_garage/src/env_dotenv" var failures = 0 var checks = 0 proc check(name: string, ok: bool) = inc checks if ok: echo "PASS: ", name else: echo "FAIL: ", name; inc failures let tmpDir = getTempDir() / "test_env_dotenv_guard" createDir(tmpDir) proc tmpPath(name: string): string = tmpDir / name proc writeTmp(name, content: string): string = result = tmpPath(name) writeFile(result, content) # ── 1. the parser ──────────────────────────────────────────────────────────── proc testParser() = let entries = parseEnvFileContent("""# a comment, ignored TR_MOVEMENT=tfil export TR_RACK_TMHORIZON=both QUOTED="hello world" SINGLE='x y' EMPTY= SPACED = trimmed """, "sample.env") check "comment + blank lines are skipped (6 entries)", entries.len == 6 if entries.len == 6: check "plain KEY=VALUE", entries[0].key == "TR_MOVEMENT" and entries[0].value == "tfil" check "leading `export ` is tolerated", entries[1].key == "TR_RACK_TMHORIZON" and entries[1].value == "both" check "double quotes are stripped", entries[2].key == "QUOTED" and entries[2].value == "hello world" check "single quotes are stripped", entries[3].key == "SINGLE" and entries[3].value == "x y" check "an empty value is allowed", entries[4].key == "EMPTY" and entries[4].value == "" check "whitespace around key and value is trimmed", entries[5].key == "SPACED" and entries[5].value == "trimmed" let crlf = parseEnvFileContent("A=1\r\nB=two\r\n", "crlf.env") check "CRLF line endings are stripped from the value", crlf.len == 2 and crlf[0].value == "1" and crlf[1].value == "two" var raised = false try: discard parseEnvFileContent("this line has no equals sign\n", "bad.env") except ValueError as e: raised = true check "malformed line error names the file", "bad.env" in e.msg check "malformed line error names the line number", ":1:" in e.msg check "malformed line RAISES (never silently ignored)", raised var emptyKeyRaised = false try: discard parseEnvFileContent("=value\n", "bad2.env") except ValueError: emptyKeyRaised = true check "an empty key raises", emptyKeyRaised # ── 2. resolution precedence ───────────────────────────────────────────────── proc testResolution() = let flagChoice = chooseEnvFile("/x/flag.env", "/x/tr.env", "/cwd/.env", "/exe/.env") check "flag beats TR_ENV_FILE and the defaults", flagChoice.source == "flag" and flagChoice.path == "/x/flag.env" and flagChoice.explicit let envChoice = chooseEnvFile("", "/x/tr.env", "/cwd/.env", "/exe/.env") check "TR_ENV_FILE beats the default paths", envChoice.source == EnvFileEnvVar and envChoice.path == "/x/tr.env" and envChoice.explicit let cwdFile = writeTmp("cwd.env", "A=1\n") let exeFile = writeTmp("exe.env", "A=2\n") let cwdChoice = chooseEnvFile("", "", cwdFile, exeFile) check "./.env (cwd) is chosen when it exists and is not explicit", cwdChoice.source == "cwd" and cwdChoice.path == cwdFile and not cwdChoice.explicit let exeChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), exeFile) check ".env next to the executable is the fallback", exeChoice.source == "exe" and exeChoice.path == exeFile let noneChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), tmpPath("missing_exe.env")) check "no candidate exists -> none (silent)", noneChoice.path == "" and noneChoice.source == "none" and not noneChoice.explicit check "--env-file is parsed", flagEnvFilePath(@["--server", "x", "--env-file", "/a/b.env"]) == "/a/b.env" check "--env-file= is parsed", flagEnvFilePath(@["--env-file=/a/b.env"]) == "/a/b.env" check "no --env-file yields empty", flagEnvFilePath(@["--server", "x"]) == "" var raised = false try: discard flagEnvFilePath(@["--env-file"]) except EnvFileError: raised = true check "--env-file with no argument raises", raised # ── 3. the file wins over the real environment ─────────────────────────────── proc testFileWins() = let f = writeTmp("wins.env", "TR_TEST_J126=from-file\nTR_TEST_SAME_J126=same\n") putEnv("TR_TEST_J126", "from-shell") putEnv("TR_TEST_SAME_J126", "same") let conflicts = applyEnvFile(f) check "the file value WINS over the exported value", getEnv("TR_TEST_J126") == "from-file" check "the conflict is reported with key, file value and shell value", conflicts.len == 1 and conflicts[0].key == "TR_TEST_J126" and conflicts[0].fileValue == "from-file" and conflicts[0].shellValue == "from-shell" check "an identical value is NOT a conflict", conflicts.len == 1 # TR_TEST_SAME_J126 is absent from conflicts check "keys from the file are tracked for the boot report", isFromEnvFile("TR_TEST_J126") and isFromEnvFile("TR_TEST_SAME_J126") delEnv("TR_TEST_J126") delEnv("TR_TEST_SAME_J126") # ── 4. missing explicit file vs missing default file ───────────────────────── proc testMissingFiles() = var raised = false try: discard loadEnvFile(EnvFileChoice(path: tmpPath("nope.env"), source: "flag", explicit: true)) except EnvFileError as e: raised = true check "missing explicit file error names the path", "nope.env" in e.msg check "missing explicit file RAISES (caller exits nonzero)", raised var quiet = true var n = 0 try: n = loadEnvFile(EnvFileChoice(path: tmpPath("missing-default.env"), source: "cwd", explicit: false)).len except CatchableError: quiet = false check "missing default file is a silent no-op", quiet and n == 0 check "no file at all is a silent no-op", loadEnvFile(EnvFileChoice(path: "", source: "none", explicit: false)).len == 0 # ── 5. inline comments (j139) and the junk-value warning ───────────────────── proc testInlineComments() = let e = parseEnvFileContent( "A=value # comment\n" & "B=\"a # b\"\n" & "C='a # b'\n" & "D=value#notacomment\n" & "E=# comment\n" & "# whole line\n" & "F=0 # hides the heat grid\n" & "G=both\n", "inline.env") check "inline comment file yields 7 entries", e.len == 7 if e.len == 7: check "`KEY=value # comment` -> value", e[0].key == "A" and e[0].value == "value" check "a `#` inside double quotes is DATA", e[1].value == "a # b" check "a `#` inside single quotes is DATA", e[2].value == "a # b" check "`KEY=value#notacomment` keeps the `#` (no whitespace before it)", e[3].value == "value#notacomment" check "`KEY=# comment` -> empty value (not an error)", e[4].value == "" check "the reported bug shape `0 # comment` -> `0`", e[5].value == "0" check "a line whose only content is a comment is still skipped", e[6].value == "both" check "stripInlineComment is pure and exact", stripInlineComment("x # y").strip == "x" and stripInlineComment(" # y").strip == "" and stripInlineComment("\"a # b\"").strip == "\"a # b\"" proc testJunkWarning() = check "a plain token is never junky", not looksJunky("0") and not looksJunky("off") and not looksJunky("1.0,1.25") and not looksJunky("/tmp/modularbot.log") and not looksJunky("") check "whitespace / `#` / unbalanced quotes are junky", looksJunky("0 # hides the grid") and looksJunky("a # b") and looksJunky("\"unclosed") # A junky value the inline-comment rule CANNOT fix: an unclosed quote, so # the `#` is (correctly) treated as data and the whole tail stays in the value. let f = writeTmp("junk.env", "TR_TEST_JUNK_J139=\"0 # hides the grid\n") let conflicts = applyEnvFile(f) check "a junky value is still APPLIED verbatim (the reader decides)", conflicts.len == 0 and getEnv("TR_TEST_JUNK_J139") == "\"0 # hides the grid" delEnv("TR_TEST_JUNK_J139") # the warning path itself: one line per offending key, naming file, key, # raw value and what is used. let w = junkWarnings(parseEnvFileContent(readFile(f), f), f) check "a junky value produces exactly one warning", w.len == 1 check "the warning names the file", w.len == 1 and f in w[0] check "the warning names the key and the raw value", w.len == 1 and "TR_TEST_JUNK_J139" in w[0] and "# hides the grid" in w[0] check "the warning says what is used and how to fix it", w.len == 1 and "DEFAULT" in w[0] and "its own line" in w[0] check "a clean file produces no warning", junkWarnings(parseEnvFileContent("A=0 # fine\nB=1.0,1.25\n", "c.env"), "c.env").len == 0 check "one line per key, no spam", junkWarnings(parseEnvFileContent("A=x y\nB=p q\n", "d.env"), "d.env").len == 2 # ── driver ─────────────────────────────────────────────────────────────────── testParser() testResolution() testFileWins() testMissingFiles() testInlineComments() testJunkWarning() echo "\n", checks, " checks, ", failures, " failure(s)" if failures > 0: echo checks, " check(s) run, ", failures, " FAILED" quit(1) echo "All env-dotenv checks passed."