Support config values via ENV

Change-Id: I95940c27b9ab36469ffedb564188533259a3544f
diff --git a/config/config.go b/config/config.go
index a4f9f77..cebf10d 100644
--- a/config/config.go
+++ b/config/config.go
@@ -3,6 +3,7 @@
 import (
 	"fmt"
 	"os"
+	"strconv"
 
 	"github.com/KorAP/Koral-Mapper/ast"
 	"github.com/KorAP/Koral-Mapper/parser"
@@ -196,6 +197,9 @@
 		Lists:      allLists,
 	}
 
+	// Apply environment variable overrides (ENV > config file)
+	ApplyEnvOverrides(result)
+
 	// Apply defaults if not specified
 	ApplyDefaults(result)
 
@@ -224,6 +228,32 @@
 	}
 }
 
+// ApplyEnvOverrides overrides configuration fields from environment variables.
+// All environment variables are uppercase and prefixed with KORAL_MAPPER_.
+// Non-empty environment values override any previously loaded config values.
+func ApplyEnvOverrides(config *MappingConfig) {
+	envMappings := map[string]*string{
+		"KORAL_MAPPER_SERVER":      &config.Server,
+		"KORAL_MAPPER_SDK":         &config.SDK,
+		"KORAL_MAPPER_STYLESHEET":  &config.Stylesheet,
+		"KORAL_MAPPER_SERVICE_URL": &config.ServiceURL,
+		"KORAL_MAPPER_COOKIE_NAME": &config.CookieName,
+		"KORAL_MAPPER_LOG_LEVEL":   &config.LogLevel,
+	}
+
+	for envKey, field := range envMappings {
+		if val := os.Getenv(envKey); val != "" {
+			*field = val
+		}
+	}
+
+	if val := os.Getenv("KORAL_MAPPER_PORT"); val != "" {
+		if port, err := strconv.Atoi(val); err == nil {
+			config.Port = port
+		}
+	}
+}
+
 // validateMappingLists validates a slice of mapping lists (without duplicate ID checking)
 func validateMappingLists(lists []MappingList) error {
 	for i, list := range lists {
diff --git a/config/config_test.go b/config/config_test.go
index 4ba8913..0046c97 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -951,6 +951,171 @@
 	assert.Contains(t, err.Error(), "failed to parse corpus mapping rule")
 }
 
+func TestApplyEnvOverrides(t *testing.T) {
+	envKeys := []string{
+		"KORAL_MAPPER_SERVER",
+		"KORAL_MAPPER_SDK",
+		"KORAL_MAPPER_STYLESHEET",
+		"KORAL_MAPPER_SERVICE_URL",
+		"KORAL_MAPPER_COOKIE_NAME",
+		"KORAL_MAPPER_PORT",
+		"KORAL_MAPPER_LOG_LEVEL",
+	}
+
+	clearEnv := func() {
+		for _, key := range envKeys {
+			os.Unsetenv(key)
+		}
+	}
+
+	t.Run("ENV overrides config values", func(t *testing.T) {
+		clearEnv()
+		defer clearEnv()
+
+		cfg := &MappingConfig{
+			Server:     "from-config",
+			SDK:        "from-config",
+			Stylesheet: "from-config",
+			ServiceURL: "from-config",
+			CookieName: "from-config",
+			Port:       1234,
+			LogLevel:   "warn",
+		}
+
+		os.Setenv("KORAL_MAPPER_SERVER", "from-env-server")
+		os.Setenv("KORAL_MAPPER_SDK", "from-env-sdk")
+		os.Setenv("KORAL_MAPPER_STYLESHEET", "from-env-style")
+		os.Setenv("KORAL_MAPPER_SERVICE_URL", "from-env-url")
+		os.Setenv("KORAL_MAPPER_COOKIE_NAME", "from-env-cookie")
+		os.Setenv("KORAL_MAPPER_PORT", "9999")
+		os.Setenv("KORAL_MAPPER_LOG_LEVEL", "debug")
+
+		ApplyEnvOverrides(cfg)
+
+		assert.Equal(t, "from-env-server", cfg.Server)
+		assert.Equal(t, "from-env-sdk", cfg.SDK)
+		assert.Equal(t, "from-env-style", cfg.Stylesheet)
+		assert.Equal(t, "from-env-url", cfg.ServiceURL)
+		assert.Equal(t, "from-env-cookie", cfg.CookieName)
+		assert.Equal(t, 9999, cfg.Port)
+		assert.Equal(t, "debug", cfg.LogLevel)
+	})
+
+	t.Run("Empty ENV does not override", func(t *testing.T) {
+		clearEnv()
+		defer clearEnv()
+
+		cfg := &MappingConfig{
+			Server:     "original-server",
+			SDK:        "original-sdk",
+			Stylesheet: "original-style",
+			ServiceURL: "original-url",
+			CookieName: "original-cookie",
+			Port:       1234,
+			LogLevel:   "info",
+		}
+
+		ApplyEnvOverrides(cfg)
+
+		assert.Equal(t, "original-server", cfg.Server)
+		assert.Equal(t, "original-sdk", cfg.SDK)
+		assert.Equal(t, "original-style", cfg.Stylesheet)
+		assert.Equal(t, "original-url", cfg.ServiceURL)
+		assert.Equal(t, "original-cookie", cfg.CookieName)
+		assert.Equal(t, 1234, cfg.Port)
+		assert.Equal(t, "info", cfg.LogLevel)
+	})
+
+	t.Run("Invalid port ENV is ignored", func(t *testing.T) {
+		clearEnv()
+		defer clearEnv()
+
+		cfg := &MappingConfig{Port: 5725}
+		os.Setenv("KORAL_MAPPER_PORT", "not-a-number")
+
+		ApplyEnvOverrides(cfg)
+
+		assert.Equal(t, 5725, cfg.Port)
+	})
+
+	t.Run("Partial ENV overrides", func(t *testing.T) {
+		clearEnv()
+		defer clearEnv()
+
+		cfg := &MappingConfig{
+			Server:   "from-config",
+			SDK:      "from-config",
+			Port:     1234,
+			LogLevel: "warn",
+		}
+
+		os.Setenv("KORAL_MAPPER_SERVER", "from-env")
+		os.Setenv("KORAL_MAPPER_PORT", "8080")
+
+		ApplyEnvOverrides(cfg)
+
+		assert.Equal(t, "from-env", cfg.Server)
+		assert.Equal(t, "from-config", cfg.SDK)
+		assert.Equal(t, 8080, cfg.Port)
+		assert.Equal(t, "warn", cfg.LogLevel)
+	})
+}
+
+func TestEnvOverridesInLoadFromSources(t *testing.T) {
+	envKeys := []string{
+		"KORAL_MAPPER_SERVER",
+		"KORAL_MAPPER_SDK",
+		"KORAL_MAPPER_PORT",
+		"KORAL_MAPPER_LOG_LEVEL",
+		"KORAL_MAPPER_STYLESHEET",
+		"KORAL_MAPPER_SERVICE_URL",
+		"KORAL_MAPPER_COOKIE_NAME",
+	}
+	clearEnv := func() {
+		for _, key := range envKeys {
+			os.Unsetenv(key)
+		}
+	}
+	clearEnv()
+	defer clearEnv()
+
+	configContent := `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+port: 3000
+lists:
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`
+	tmpfile, err := os.CreateTemp("", "config-env-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(tmpfile.Name())
+
+	_, err = tmpfile.WriteString(configContent)
+	require.NoError(t, err)
+	require.NoError(t, tmpfile.Close())
+
+	os.Setenv("KORAL_MAPPER_SERVER", "https://env-override.example.com/")
+	os.Setenv("KORAL_MAPPER_PORT", "7777")
+
+	cfg, err := LoadFromSources(tmpfile.Name(), nil)
+	require.NoError(t, err)
+
+	// ENV overrides YAML values
+	assert.Equal(t, "https://env-override.example.com/", cfg.Server)
+	assert.Equal(t, 7777, cfg.Port)
+
+	// Non-overridden values preserved from YAML
+	assert.Equal(t, "https://custom.example.com/sdk.js", cfg.SDK)
+
+	// Defaults applied for unset fields
+	assert.Equal(t, defaultStylesheet, cfg.Stylesheet)
+	assert.Equal(t, defaultServiceURL, cfg.ServiceURL)
+	assert.Equal(t, defaultCookieName, cfg.CookieName)
+	assert.Equal(t, defaultLogLevel, cfg.LogLevel)
+}
+
 func TestParseCorpusMappingsWithFieldAFieldB(t *testing.T) {
 	list := &MappingList{
 		ID:     "test-keyed",