diff --git a/.gitignore b/.gitignore index 0753ab7..9dbd8bb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ resources/ai/sessions infra/Pulumi.*.yaml.backup infra/desi-explorer-infra api/target/ +.env +.env.* diff --git a/gui/Makefile b/gui/Makefile index 84951f2..00df428 100644 --- a/gui/Makefile +++ b/gui/Makefile @@ -46,7 +46,7 @@ build-web: ## WebAssembly build -> build/web (needs emscripten) test: ## Run Odin unit tests @mkdir -p lib/local - $(ODIN) test src $(ODIN_FLAGS) + $(ODIN) test test $(ODIN_FLAGS) clean: ## Remove build artifacts rm -rf $(BIN) build diff --git a/gui/lib/local/dotenv/dotenv.odin b/gui/lib/local/dotenv/dotenv.odin new file mode 100644 index 0000000..8084b6d --- /dev/null +++ b/gui/lib/local/dotenv/dotenv.odin @@ -0,0 +1,95 @@ +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/src/config.odin b/gui/src/config.odin new file mode 100644 index 0000000..f019d99 --- /dev/null +++ b/gui/src/config.odin @@ -0,0 +1,29 @@ +package main + +import "lib:dotenv" + +Config :: struct { + api_url: string, +} + +get_config :: proc() -> (^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 err := validate_config(c); err != nil { + return nil, err + } + + return c, nil +} + +validate_config :: proc(c: ^Config) -> ^Error { + err := new(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/src/error.odin b/gui/src/error.odin new file mode 100644 index 0000000..daeb84f --- /dev/null +++ b/gui/src/error.odin @@ -0,0 +1,20 @@ +package main + +import "core:fmt" +import "core:strings" +ErrorType :: enum { + Config, + API, +} + +Error :: struct { + type: ErrorType, + message: string, +} + +format_error :: proc(err: ^Error) -> string { + sb := strings.builder_make(context.temp_allocator) + + return fmt.sbprintf(&sb, "[%s] => %s", err.type, err.message) +} + diff --git a/gui/src/main.odin b/gui/src/main.odin index 67285fb..315b1cb 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -24,6 +24,13 @@ universe: [dynamic]Galaxy rng: rand.Default_Random_State main :: proc() { + + c: ^Config + + if c, err := get_config(); err != nil { + panic(format_error(err)) + } + rng = rand.create(0xDE51_0000) context.random_generator = rand.default_random_generator(&rng) @@ -103,11 +110,5 @@ draw :: proc() { } rl.DrawFPS(10, 10) - rl.DrawText( - "DESI Explorer — drag to rotate, scroll to zoom", - 10, - 34, - 18, - rl.RAYWHITE, - ) + rl.DrawText("DESI Explorer — drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE) } diff --git a/gui/test/dotenv_test.odin b/gui/test/dotenv_test.odin new file mode 100644 index 0000000..d7ba8f6 --- /dev/null +++ b/gui/test/dotenv_test.odin @@ -0,0 +1,87 @@ +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