diff --git a/gui/Makefile b/gui/Makefile index 00df428..94dca55 100644 --- a/gui/Makefile +++ b/gui/Makefile @@ -46,7 +46,14 @@ build-web: ## WebAssembly build -> build/web (needs emscripten) test: ## Run Odin unit tests @mkdir -p lib/local - $(ODIN) test test $(ODIN_FLAGS) + @if ls test/*.odin >/dev/null 2>&1; then \ + echo "== gui/test =="; \ + $(ODIN) test test $(ODIN_FLAGS); \ + fi + @for dir in $$(find lib/local -name '*_test.odin' -exec dirname {} \; | sort -u); do \ + echo "== $$dir =="; \ + $(ODIN) test "$$dir" $(ODIN_FLAGS); \ + done clean: ## Remove build artifacts rm -rf $(BIN) build diff --git a/gui/lib/local/dotenv/dotenv.odin b/gui/lib/local/dotenv/dotenv.odin deleted file mode 100644 index 8084b6d..0000000 --- a/gui/lib/local/dotenv/dotenv.odin +++ /dev/null @@ -1,95 +0,0 @@ -package dotenv - -import "core:os" -import "core:strconv" -import "core:strings" - -// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated -// with allocator. Blank lines, lines starting with '#', and lines without a -// '=' are skipped. Keys and values are trimmed; values may be wrapped in -// double quotes. Precedence with the real process environment is handled by -// the get_* accessors (real env vars win over the file). -parse :: proc(src: string, allocator := context.allocator) -> map[string]string { - result := make(map[string]string, allocator) - - it := src - for line in strings.split_lines_iterator(&it) { - tr := strings.trim_space(line) - if len(tr) == 0 || strings.has_prefix(tr, "#") { - continue - } - - eq := strings.index_byte(tr, '=') - if eq < 0 { - continue - } - - key := strings.trim_space(tr[:eq]) - if key == "" { - continue - } - - value := strings.trim_space(tr[eq + 1:]) - if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' { - value = value[1:len(value) - 1] - } - - // clone so the map outlives the source buffer (e.g. a freed file read) - result[strings.clone(key, allocator)] = strings.clone(value, allocator) - } - - return result -} - -// parse_file reads a dotenv file from disk and parses it. It returns -// (nil, false) when the file cannot be read (e.g. it does not exist). -parse_file :: proc(filename: string, allocator := context.allocator) -> (map[string]string, bool) { - data, err := os.read_entire_file(filename, allocator) - if err != nil { - return nil, false - } - defer delete(data) - - return parse(string(data), allocator), true -} - -// destroy frees the cloned keys/values and the map itself. Use it to release -// a map returned by parse/parse_file (plain delete does not free the strings). -destroy :: proc(env: map[string]string) { - for key, value in env { - delete(key) - delete(value) - } - delete(env) -} - -// get_string returns the value for key from the real process environment if -// set, otherwise from the parsed dotenv map (or "" if neither has it). -get_string :: proc(env: map[string]string, key: string) -> string { - if value, found := os.lookup_env(key, context.temp_allocator); found { - return value - } - return env[key] -} - -// get_bool resolves key and parses it as a boolean (true/false, 1/0, -// yes/no, on/off). Missing or unparsable values yield default. -get_bool :: proc(env: map[string]string, key: string, default := false) -> bool { - if v := get_string(env, key); v != "" { - if parsed, ok := strconv.parse_bool(v); ok { - return parsed - } - } - return default -} - -// get_int resolves key and parses it as an integer. Missing or unparsable -// values yield default. -get_int :: proc(env: map[string]string, key: string, default := 0) -> int { - if v := get_string(env, key); v != "" { - if parsed, ok := strconv.parse_int(v); ok { - return parsed - } - } - return default -} \ No newline at end of file diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin new file mode 100644 index 0000000..ebfccb8 --- /dev/null +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -0,0 +1,165 @@ +package dotenv + +import "base:runtime" +import "core:os" +import "core:reflect" +import "core:strconv" +import "core:strings" + +// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated +// with allocator. Blank lines, lines starting with '#', and lines without a +// '=' are skipped. Keys and values are trimmed; values may be wrapped in +// double quotes. Real process environment variables take precedence over the +// file. The returned map owns its keys/values; release it with destroy. +@(private) +parse :: proc( + src: string, + allocator := context.allocator, +) -> map[string]string { + result := make(map[string]string, allocator) + + it := src + for line in strings.split_lines_iterator(&it) { + tr := strings.trim_space(line) + if len(tr) == 0 || strings.has_prefix(tr, "#") { + continue + } + + eq := strings.index_byte(tr, '=') + if eq < 0 { + continue + } + + key := strings.trim_space(tr[:eq]) + if key == "" { + continue + } + + value := strings.trim_space(tr[eq + 1:]) + if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' { + value = value[1:len(value) - 1] + } + + // real process env vars win over the file + if override, found := os.lookup_env(key, context.temp_allocator); found { + value = override + } + + // clone so the map outlives the source buffer (e.g. a freed file read) + result[strings.clone(key, allocator)] = strings.clone(value, allocator) + } + + return result +} + +// parse_file reads a dotenv file from disk and parses it into a map. It +// returns (nil, false) when the file cannot be read (e.g. it does not exist). +parse_file :: proc( + filename: string, + allocator := context.allocator, +) -> ( + map[string]string, + bool, +) { + data, err := os.read_entire_file(filename, allocator) + if err != nil { + return nil, false + } + defer delete(data) + + return parse(string(data), allocator), true +} + +// destroy frees the cloned keys/values and the map itself. Use it to release +// a map returned by parse/parse_file (plain delete does not free the strings). +destroy :: proc(env: map[string]string) { + for key, value in env { + delete(key) + delete(value) + } + delete(env) +} + +// decode populates dest's fields from env, matching each field by name +// (case-insensitively, so API_URL maps onto api_url). Values are converted +// to the field's type: string is cloned as-is into allocator, integers are +// parsed with strconv.parse_int (decimal/hex/negative), booleans with +// strconv.parse_bool, and floats with strconv.parse_f64. Keys missing from +// env leave the field at its zero value. It returns false if a present value +// cannot be converted to the field's type. +decode :: proc( + env: map[string]string, + dest: ^$T, + allocator := context.allocator, +) -> bool { + ti := reflect.type_info_base(type_info_of(T)) + fields, ok := ti.variant.(runtime.Type_Info_Struct) + if !ok { + return false + } + + name: string + value: string + field_ptr := rawptr(dest) + for _, i in fields.names[:fields.field_count] { + name = fields.names[i] + value = "" + found := false + for key, v in env { + if key == name || strings.equal_fold(key, name) { + value, found = v, true + break + } + } + if !found { + continue + } + + field_ptr = rawptr(uintptr(dest) + fields.offsets[i]) + field_ti := reflect.type_info_base(fields.types[i]) + #partial switch variant in field_ti.variant { + case runtime.Type_Info_String: + (^string)(field_ptr)^ = strings.clone(value, allocator) + case runtime.Type_Info_Integer: + parsed, err := strconv.parse_int(value) + if !err { + return false + } + switch field_ti.size { + case 1: + (^i8)(field_ptr)^ = cast(i8)parsed + case 2: + (^i16)(field_ptr)^ = cast(i16)parsed + case 4: + (^i32)(field_ptr)^ = cast(i32)parsed + case 8: + (^i64)(field_ptr)^ = cast(i64)parsed + case: + return false + } + case runtime.Type_Info_Boolean: + parsed, err := strconv.parse_bool(value) + if !err { + return false + } + (^bool)(field_ptr)^ = parsed + case runtime.Type_Info_Float: + parsed, err := strconv.parse_f64(value) + if !err { + return false + } + switch field_ti.size { + case 4: + (^f32)(field_ptr)^ = cast(f32)parsed + case 8: + (^f64)(field_ptr)^ = parsed + case: + return false + } + case: + // unsupported field type (slices, pointers, ...) is left untouched + } + } + + return true +} diff --git a/gui/lib/local/dotenv/test/dotenv_test.odin b/gui/lib/local/dotenv/test/dotenv_test.odin new file mode 100644 index 0000000..e0f17fa --- /dev/null +++ b/gui/lib/local/dotenv/test/dotenv_test.odin @@ -0,0 +1,201 @@ +package dotenv_tests + +import "core:os" +import "core:strings" +import "core:testing" +import dotenv "lib:dotenv/src" + +Test_Config :: struct { + api_url: string, + debug: bool, + port: int, + ratio: f64, +} + +// load_env writes src to a unique temp file and parses it via parse_file. +// The returned map owns its strings; callers must destroy it. +load_env :: proc(t: ^testing.T, src: string) -> map[string]string { + dir, err := os.make_directory_temp("", "dotenv_test_*", context.allocator) + testing.expect(t, err == nil, "expected temp dir to be created") + defer os.remove_all(dir) + defer delete(dir) + + path := strings.concatenate({dir, "/.env"}) + defer delete(path) + + testing.expect( + t, + os.write_entire_file(path, src) == nil, + "expected file write to succeed", + ) + + env, ok := dotenv.parse_file(path) + testing.expect(t, ok, "expected parse_file to succeed") + return env +} + +@(test) +test_parse_basic :: proc(t: ^testing.T) { + env := load_env(t, "API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n") + defer dotenv.destroy(env) + + testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080") + testing.expect(t, env["DEBUG"] == "true") + testing.expect(t, env["PORT"] == "8080") +} + +@(test) +test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) { + env := load_env(t, "# leading comment\n\n \nFOO=bar \nBAZ = qux \n") + defer dotenv.destroy(env) + + testing.expect(t, env["FOO"] == "bar", "value should be trimmed") + testing.expect( + t, + env["BAZ"] == "qux", + "key and value should be trimmed around '='", + ) + testing.expect( + t, + "API_URL" not_in env, + "comment-only lines should not be parsed", + ) +} + +@(test) +test_parse_quoted_values :: proc(t: ^testing.T) { + env := load_env(t, "GREETING=\"hello world\"\nEMPTY=\"\"\n") + defer dotenv.destroy(env) + + testing.expect( + t, + env["GREETING"] == "hello world", + "quoted value with inner space", + ) + testing.expect(t, env["EMPTY"] == "", "double-quoted empty value") +} + +@(test) +test_parse_skips_lines_without_equals :: proc(t: ^testing.T) { + env := load_env(t, "not-an-assignment\nOK=yep\n") + defer dotenv.destroy(env) + + testing.expect(t, env["OK"] == "yep") + testing.expect( + t, + "not-an-assignment" not_in env, + "line without '=' should be skipped", + ) +} + +@(test) +test_parse_missing_file :: proc(t: ^testing.T) { + env, ok := dotenv.parse_file("/nonexistent/dotenv_test_does_not_exist.env") + testing.expect(t, !ok, "missing file should report failure") + testing.expect(t, env == nil, "missing file should return nil map") +} + +@(test) +test_real_env_overrides_file :: proc(t: ^testing.T) { + testing.expect(t, os.set_env("DESI_EXPLORER_TEST_FOO", "from_env") == nil) + defer os.unset_env("DESI_EXPLORER_TEST_FOO") + + env := load_env(t, "DESI_EXPLORER_TEST_FOO=from_file\n") + defer dotenv.destroy(env) + + testing.expect( + t, + env["DESI_EXPLORER_TEST_FOO"] == "from_env", + "real env var should win over file", + ) +} + +@(test) +test_decode_maps_fields_case_insensitively :: proc(t: ^testing.T) { + env := load_env( + t, + "API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\nRATIO=0.5\n", + ) + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://127.0.0.1:8080", + "API_URL maps onto api_url", + ) + testing.expect(t, cfg.debug == true, "DEBUG=true should decode to true") + testing.expect(t, cfg.port == 8080, "PORT=8080 should decode to int 8080") + testing.expect(t, cfg.ratio == 0.5, "RATIO=0.5 should decode to f64 0.5") +} + +@(test) +test_decode_matches_exact_and_lowercase_keys :: proc(t: ^testing.T) { + env := load_env(t, "api_url=http://exact\nPort=9090\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://exact", + "exact-case key should match", + ) + testing.expect(t, cfg.port == 9090, "mixed-case key should match field") +} + +@(test) +test_decode_missing_keys_leave_zero_values :: proc(t: ^testing.T) { + env := load_env(t, "UNRELATED=value\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + + testing.expect(t, cfg.api_url == "") + testing.expect(t, !cfg.debug) + testing.expect(t, cfg.port == 0) + testing.expect(t, cfg.ratio == 0) +} + +@(test) +test_decode_unparsable_int_fails :: proc(t: ^testing.T) { + env := load_env(t, "PORT=oops\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect( + t, + !dotenv.decode(env, &cfg), + "unparsable int should make decode fail", + ) +} + +@(test) +test_decode_unparsable_bool_fails :: proc(t: ^testing.T) { + env := load_env(t, "DEBUG=maybe\nAPI_URL=http://127.0.0.1:8080\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect( + t, + !dotenv.decode(env, &cfg), + "unparsable bool should make decode fail", + ) + defer delete(cfg.api_url) +} + +@(test) +test_decode_hex_and_negative_ints :: proc(t: ^testing.T) { + env := load_env(t, "PORT=0x1F\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + testing.expect(t, cfg.port == 31, "hex int should decode") +} diff --git a/gui/src/config.odin b/gui/src/config.odin index f019d99..b5caa65 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -1,18 +1,26 @@ package main -import "lib:dotenv" +import "core:os" +import dotenv "lib:dotenv/src" Config :: struct { api_url: string, } -get_config :: proc() -> (^Config, ^Error) { +get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { env, _ := dotenv.parse_file(".env", context.temp_allocator) defer dotenv.destroy(env) c := new(Config) - c.api_url = dotenv.get_string(env, "API_URL") + if env == nil { + c.api_url = os.get_env("API_URL", context.temp_allocator) + } else if !dotenv.decode(env, c) { + err := new(Error) + err.type = .Config + err.message = "failed to decode .env into Config" + return nil, err + } if err := validate_config(c); err != nil { return nil, err @@ -26,4 +34,4 @@ validate_config :: proc(c: ^Config) -> ^Error { if c.api_url == "" do err.type = .Config; err.message = "'API_URL' is required" return err -} \ No newline at end of file +} diff --git a/gui/test/dotenv_test.odin b/gui/test/dotenv_test.odin deleted file mode 100644 index d7ba8f6..0000000 --- a/gui/test/dotenv_test.odin +++ /dev/null @@ -1,87 +0,0 @@ -package dotenv_tests - -import "core:os" -import "core:testing" -import "lib:dotenv" - -@(test) -test_parse_basic :: proc(t: ^testing.T) { - env := dotenv.parse("API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n") - defer dotenv.destroy(env) - - testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080") - testing.expect(t, env["DEBUG"] == "true") - testing.expect(t, env["PORT"] == "8080") -} - -@(test) -test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) { - env := dotenv.parse("# leading comment\n\n \nFOO=bar \nBAZ = qux \n") - defer dotenv.destroy(env) - - testing.expect(t, env["FOO"] == "bar", "value should be trimmed") - testing.expect(t, env["BAZ"] == "qux", "key and value should be trimmed around '='") - testing.expect(t, "API_URL" not_in env, "comment-only lines should not be parsed") -} - -@(test) -test_parse_quoted_values :: proc(t: ^testing.T) { - env := dotenv.parse("GREETING=\"hello world\"\nEMPTY=\"\"\n") - defer dotenv.destroy(env) - - testing.expect(t, env["GREETING"] == "hello world", "quoted value with inner space") - testing.expect(t, env["EMPTY"] == "", "double-quoted empty value") -} - -@(test) -test_parse_skips_lines_without_equals :: proc(t: ^testing.T) { - env := dotenv.parse("not-an-assignment\nOK=yep\n") - defer dotenv.destroy(env) - - testing.expect(t, env["OK"] == "yep") - testing.expect(t, "not-an-assignment" not_in env, "line without '=' should be skipped") -} - -@(test) -test_get_string_falls_back_to_file :: proc(t: ^testing.T) { - env := dotenv.parse("FOO=from_file\n") - defer dotenv.destroy(env) - - testing.expect(t, dotenv.get_string(env, "FOO") == "from_file", "missing env var should use file value") - testing.expect(t, dotenv.get_string(env, "MISSING") == "", "absent everywhere should be empty") -} - -@(test) -test_get_string_prefers_real_env :: proc(t: ^testing.T) { - env := dotenv.parse("FOO=from_file\n") - defer dotenv.destroy(env) - - testing.expect(t, os.set_env("FOO", "from_env") == nil) - defer os.unset_env("FOO") - - testing.expect(t, dotenv.get_string(env, "FOO") == "from_env", "real env var should win over file") -} - -@(test) -test_get_bool :: proc(t: ^testing.T) { - env := dotenv.parse("A=true\nB=0\nC=FALSE\nD=garbage\n") - defer dotenv.destroy(env) - - testing.expect(t, dotenv.get_bool(env, "A", false), "\"true\" should parse to true") - testing.expect(t, !dotenv.get_bool(env, "B", true), "\"0\" should parse to false") - testing.expect(t, !dotenv.get_bool(env, "C", true), "\"FALSE\" should parse to false") - testing.expect(t, dotenv.get_bool(env, "D", true), "unparsable value should fall back to default") - testing.expect(t, dotenv.get_bool(env, "MISSING", true), "missing key should fall back to default") -} - -@(test) -test_get_int :: proc(t: ^testing.T) { - env := dotenv.parse("PORT=8080\nNEG=-42\nHEX=0x1F\nGARBAGE=abc\n") - defer dotenv.destroy(env) - - testing.expect(t, dotenv.get_int(env, "PORT", -1) == 8080, "decimal int") - testing.expect(t, dotenv.get_int(env, "NEG", 0) == -42, "negative int") - testing.expect(t, dotenv.get_int(env, "HEX", 0) == 31, "hex int") - testing.expect(t, dotenv.get_int(env, "GARBAGE", 7) == 7, "unparsable value should fall back to default") - testing.expect(t, dotenv.get_int(env, "MISSING", 7) == 7, "missing key should fall back to default") -} \ No newline at end of file