diff --git a/.gitignore b/.gitignore index 9dbd8bb..d075350 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ infra/desi-explorer-infra api/target/ .env .env.* +*.env diff --git a/api/Makefile b/api/Makefile index 36d94a7..3de9787 100644 --- a/api/Makefile +++ b/api/Makefile @@ -4,6 +4,20 @@ # `api-*` convenience targets). CARGO ?= cargo +ROOT := .. + +# Normalize API_ENV_FILE / API_DESI_DATA (given relative to the repo root) to +# absolute paths so the API process can open them regardless of its working +# directory. "override" is required because they are usually passed as +# command-line/env vars, which would otherwise override any assignment here. +define normalize_path +ifdef $1 +ifneq ($(abspath $($1)),$($1)) +override $1 := $(abspath $(ROOT)/$($1)) +endif +endif +endef +$(foreach v,API_ENV_FILE API_DESI_DATA,$(eval $(call normalize_path,$v))) .PHONY: help setup run build test check fmt clean diff --git a/api/src/store.rs b/api/src/store.rs index 8db1fa7..9220dcb 100644 --- a/api/src/store.rs +++ b/api/src/store.rs @@ -55,56 +55,3 @@ impl CatalogStore { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn load_parses_catalogs_and_objects() { - let json = r#"{ - "catalogs": [ - {"name":"edr","release":"EDR","description":"test","object_count":2} - ], - "objects": [ - {"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8} - ] - }"#; - - let dir = std::env::temp_dir().join(format!( - "desi_explorer_store_{}_{}", - std::process::id(), - line!() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("data.json"); - std::fs::write(&path, json).unwrap(); - - let store = CatalogStore::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - - assert_eq!(store.catalogs.len(), 1); - assert_eq!(store.catalogs[0].name, "edr"); - assert_eq!(store.catalogs[0].object_count, Some(2)); - assert_eq!(store.objects.len(), 1); - assert_eq!(store.objects[0].id, "o1"); - assert_eq!(store.objects[0].ra, 1.5); - } - - #[test] - fn load_rejects_malformed_json() { - let dir = std::env::temp_dir().join(format!( - "desi_explorer_store_{}_{}", - std::process::id(), - line!() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("data.json"); - std::fs::write(&path, "not json").unwrap(); - - let result = CatalogStore::load(&path); - let _ = std::fs::remove_dir_all(&dir); - - assert!(result.is_err()); - } -} diff --git a/api/tests/store.rs b/api/tests/store.rs new file mode 100644 index 0000000..4954aaa --- /dev/null +++ b/api/tests/store.rs @@ -0,0 +1,54 @@ +use desi_explorer_api::store::CatalogStore; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_parses_catalogs_and_objects() { + let json = r#"{ + "catalogs": [ + {"name":"edr","release":"EDR","description":"test","object_count":2} + ], + "objects": [ + {"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8} + ] + }"#; + + let dir = std::env::temp_dir().join(format!( + "desi_explorer_store_{}_{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, json).unwrap(); + + let store = CatalogStore::load(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(store.catalogs.len(), 1); + assert_eq!(store.catalogs[0].name, "edr"); + assert_eq!(store.catalogs[0].object_count, Some(2)); + assert_eq!(store.objects.len(), 1); + assert_eq!(store.objects[0].id, "o1"); + assert_eq!(store.objects[0].ra, 1.5); + } + + #[test] + fn load_rejects_malformed_json() { + let dir = std::env::temp_dir().join(format!( + "desi_explorer_store_{}_{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, "not json").unwrap(); + + let result = CatalogStore::load(&path); + let _ = std::fs::remove_dir_all(&dir); + + assert!(result.is_err()); + } +} diff --git a/gui/Makefile b/gui/Makefile index 94dca55..a0c71a9 100644 --- a/gui/Makefile +++ b/gui/Makefile @@ -8,13 +8,28 @@ # output dirs live at the repo root and are referenced through `$(ROOT)`. ODIN ?= odin +GDB ?= gdb ROOT := .. BIN := $(ROOT)/bin BINARY := $(BIN)/desi_explorer ODIN_FLAGS := -collection:lib=lib/local WASM_DEFINE := RAYLIB_WASM_LIB=env.o -.PHONY: help setup add-dep run build build-debug build-web test clean fmt +# Normalize GUI_ENV_FILE (given relative to the repo root) to an absolute path +# so the Odin process can open it regardless of its working directory. +# "override" is required because GUI_ENV_FILE is usually set on the command +# line (or passed as an env var to this sub-make), which would otherwise +# override any assignment made here. +ifdef GUI_ENV_FILE +ifneq ($(abspath $(GUI_ENV_FILE)),$(GUI_ENV_FILE)) +override GUI_ENV_FILE := $(abspath $(ROOT)/$(GUI_ENV_FILE)) +endif +endif +# "override" on a command-line variable silently drops it from the recipe +# environment; re-export it so the app can find its env file (run + gdb). +export GUI_ENV_FILE + +.PHONY: help setup add-dep run build build-debug gdb build-web test clean fmt help: ## List available targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @@ -41,6 +56,9 @@ build-debug: ## Debug build -> bin/desi_explorer @mkdir -p lib/local $(BIN) $(ODIN) build src $(ODIN_FLAGS) -o:none -debug -out:$(BINARY) +gdb: build-debug ## Run the native app under gdb (type 'run', then 'bt' on a crash) + $(GDB) -q --args $(BINARY) $(ARGS) + build-web: ## WebAssembly build -> build/web (needs emscripten) @scripts/build_web.sh diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin index 9480bef..047da75 100644 --- a/gui/lib/local/dotenv/src/dotenv.odin +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -12,10 +12,7 @@ import "core:strings" // 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 { +parse :: proc(src: string, allocator := context.allocator) -> map[string]string { result := make(map[string]string, allocator) it := src @@ -54,18 +51,12 @@ parse :: proc( // 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, -) { +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) + defer delete(data, allocator) return parse(string(data), allocator), true } @@ -86,8 +77,7 @@ destroy :: proc(env: map[string]string) { // case-insensitive, so API_URL maps onto api_url (or an `env:"API_URL"` tag). @(private) env_key_for_field :: proc(field: reflect.Struct_Field) -> string { - if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); - ok && tag_key != "" { + if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); ok && tag_key != "" { return tag_key } return field.name @@ -100,11 +90,7 @@ env_key_for_field :: proc(field: reflect.Struct_Field) -> string { // 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 { +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 { diff --git a/gui/src/config.odin b/gui/src/config.odin index b6f06b7..1aa9ea4 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -11,10 +11,7 @@ get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { path := ".env" if env_file != nil && env_file^ != "" { path = env_file^ - } else if from_env, ok := os.lookup_env( - "GUI_ENV_FILE", - context.temp_allocator, - ); ok { + } else if from_env, ok := os.lookup_env("GUI_ENV_FILE", context.temp_allocator); ok { path = from_env } @@ -39,9 +36,9 @@ get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { 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" - +validate_config :: proc(c: ^Config) -> (err: ^Error) { + if c.api_url == "" { + err = &Error{.Config, "'API_URL' is required"} + } return err } diff --git a/gui/src/main.odin b/gui/src/main.odin index 315b1cb..06213d8 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -1,7 +1,9 @@ package main +import "base:runtime" import "core:math" import "core:math/rand" +import "core:os" import rl "vendor:raylib" WIDTH :: 1280 @@ -27,7 +29,9 @@ main :: proc() { c: ^Config - if c, err := get_config(); err != nil { + s := os.get_env("GUI_ENV_FILE", context.temp_allocator) + + if c, err := get_config(&s); err != nil { panic(format_error(err)) }