Support rewrite default config from config file or ENV var

Change-Id: Ibd8529b545f3c49fe1e57043e8d5293ff72dfdd1
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index 50bdace..66cd96b 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -371,14 +371,14 @@
 	app.Get("/static/*", handleStaticFile())
 
 	// Composite cascade transformation endpoints (cfg in path)
-	app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig.Lists))
-	app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig.Lists))
+	app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig))
+	app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig))
 
 	// Transformation endpoint
-	app.Post("/:map/query", handleTransform(m, yamlConfig.Lists))
+	app.Post("/:map/query", handleTransform(m, yamlConfig))
 
 	// Response transformation endpoint
-	app.Post("/:map/response", handleResponseTransform(m, yamlConfig.Lists))
+	app.Post("/:map/response", handleResponseTransform(m, yamlConfig))
 
 	// Kalamar plugin endpoint
 	app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
@@ -462,10 +462,10 @@
 	return data
 }
 
-func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
-	listsByID := make(map[string]*config.MappingList, len(lists))
-	for i := range lists {
-		listsByID[lists[i].ID] = &lists[i]
+func handleCompositeQueryTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
+	for i := range yamlConfig.Lists {
+		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
 	return func(c *fiber.Ctx) error {
@@ -483,7 +483,7 @@
 			})
 		}
 
-		entries, err := ParseCfgParam(cfgRaw, lists)
+		entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
 		if err != nil {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
 				"error": err.Error(),
@@ -509,9 +509,9 @@
 				dir = mapper.BtoA
 			}
 
-			addRewrites := false
+			addRewrites := yamlConfig.Rewrites
 			if list, ok := listsByID[entry.ID]; ok {
-				addRewrites = list.Rewrites
+				addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
 			}
 			if rewritesOverride != nil {
 				addRewrites = *rewritesOverride
@@ -542,10 +542,10 @@
 	}
 }
 
-func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
-	listsByID := make(map[string]*config.MappingList, len(lists))
-	for i := range lists {
-		listsByID[lists[i].ID] = &lists[i]
+func handleCompositeResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
+	for i := range yamlConfig.Lists {
+		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
 	return func(c *fiber.Ctx) error {
@@ -563,7 +563,7 @@
 			})
 		}
 
-		entries, err := ParseCfgParam(cfgRaw, lists)
+		entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
 		if err != nil {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
 				"error": err.Error(),
@@ -589,9 +589,9 @@
 				dir = mapper.BtoA
 			}
 
-			addRewrites := false
+			addRewrites := yamlConfig.Rewrites
 			if list, ok := listsByID[entry.ID]; ok {
-				addRewrites = list.Rewrites
+				addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
 			}
 			if rewritesOverride != nil {
 				addRewrites = *rewritesOverride
@@ -622,10 +622,10 @@
 	}
 }
 
-func handleTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
-	listsByID := make(map[string]*config.MappingList, len(lists))
-	for i := range lists {
-		listsByID[lists[i].ID] = &lists[i]
+func handleTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
+	for i := range yamlConfig.Lists {
+		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
 	return func(c *fiber.Ctx) error {
@@ -645,10 +645,10 @@
 			})
 		}
 
-		// Determine rewrites: query param overrides YAML default
-		addRewrites := false
+		// Resolve rewrites: global default -> per-list -> query param
+		addRewrites := yamlConfig.Rewrites
 		if list, ok := listsByID[params.MapID]; ok {
-			addRewrites = list.Rewrites
+			addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
 		}
 		if params.Rewrites != nil {
 			addRewrites = *params.Rewrites
@@ -679,10 +679,10 @@
 	}
 }
 
-func handleResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
-	listsByID := make(map[string]*config.MappingList, len(lists))
-	for i := range lists {
-		listsByID[lists[i].ID] = &lists[i]
+func handleResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
+	for i := range yamlConfig.Lists {
+		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
 	return func(c *fiber.Ctx) error {
@@ -702,10 +702,10 @@
 			})
 		}
 
-		// Determine rewrites: query param overrides YAML default
-		addRewrites := false
+		// Resolve rewrites: global default -> per-list -> query param
+		addRewrites := yamlConfig.Rewrites
 		if list, ok := listsByID[params.MapID]; ok {
-			addRewrites = list.Rewrites
+			addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
 		}
 		if params.Rewrites != nil {
 			addRewrites = *params.Rewrites
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index b5e6da7..d0d77f2 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -2990,3 +2990,331 @@
 		resp.Header.Get("Access-Control-Allow-Origin"),
 		"transform endpoints should include CORS headers")
 }
+
+// Helper for rewrites tests: sends a POST to the given URL with a standard input
+// and returns whether the result's wrap contains a "rewrites" field.
+func hasRewritesInResponse(t *testing.T, app *fiber.App, url string) bool {
+	t.Helper()
+	input := `{
+		"@type": "koral:token",
+		"wrap": {
+			"@type": "koral:term",
+			"foundry": "opennlp",
+			"key": "PIDAT",
+			"layer": "p",
+			"match": "match:eq"
+		}
+	}`
+
+	req := httptest.NewRequest(http.MethodPost, url, bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var result map[string]any
+	err = json.NewDecoder(resp.Body).Decode(&result)
+	require.NoError(t, err)
+
+	wrap, ok := result["wrap"].(map[string]any)
+	require.True(t, ok, "result should have a wrap field")
+	_, hasRewrites := wrap["rewrites"]
+	return hasRewrites
+}
+
+func TestGlobalRewritesTrueInheritsToList(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.True(t, hasRewritesInResponse(t, app, "/test-mapper/query?dir=atob"),
+		"global rewrites=true should be inherited by list without per-list override")
+}
+
+func TestGlobalRewritesTrueOverriddenByListFalse(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    rewrites: false
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.False(t, hasRewritesInResponse(t, app, "/test-mapper/query?dir=atob"),
+		"per-list rewrites=false should override global rewrites=true")
+}
+
+func TestGlobalRewritesFalseOverriddenByListTrue(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    rewrites: true
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.True(t, hasRewritesInResponse(t, app, "/test-mapper/query?dir=atob"),
+		"per-list rewrites=true should override global rewrites=false (default)")
+}
+
+func TestQueryParamOverridesGlobalRewritesTrue(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.False(t, hasRewritesInResponse(t, app, "/test-mapper/query?dir=atob&rewrites=false"),
+		"query param rewrites=false should override global rewrites=true")
+}
+
+func TestQueryParamOverridesPerListRewritesFalse(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    rewrites: false
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.True(t, hasRewritesInResponse(t, app, "/test-mapper/query?dir=atob&rewrites=true"),
+		"query param rewrites=true should override per-list rewrites=false")
+}
+
+func TestCompositeQueryGlobalRewritesInherited(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: step1
+    foundryA: opennlp
+    layerA: p
+    foundryB: stts
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.True(t, hasRewritesInResponse(t, app, "/query/step1:atob"),
+		"composite query should inherit global rewrites=true")
+}
+
+func TestCompositeQueryGlobalRewritesOverriddenByQueryParam(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: step1
+    foundryA: opennlp
+    layerA: p
+    foundryB: stts
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.False(t, hasRewritesInResponse(t, app, "/query/step1:atob?rewrites=false"),
+		"composite query param rewrites=false should override global rewrites=true")
+}
+
+func TestCompositeQueryPerListOverridesGlobal(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: step1
+    foundryA: opennlp
+    layerA: p
+    foundryB: stts
+    layerB: p
+    rewrites: false
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	assert.False(t, hasRewritesInResponse(t, app, "/query/step1:atob"),
+		"composite query should use per-list rewrites=false over global rewrites=true")
+}
+
+func TestResponseEndpointGlobalRewritesInherited(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: test-response-mapper
+    foundryA: marmot
+    layerA: m
+    foundryB: opennlp
+    layerB: p
+    mappings:
+      - "[gender:masc] <> [p=M & m=M]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	// Response rewrites on snippets don't use koral:rewrite fields the same way
+	// but we can verify the handler doesn't error and respects the flag.
+	input := `{"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"}`
+	req := httptest.NewRequest(http.MethodPost, "/test-response-mapper/response?dir=atob",
+		bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+}
+
+func TestResponseEndpointQueryParamOverridesGlobal(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: test-response-mapper
+    foundryA: marmot
+    layerA: m
+    foundryB: opennlp
+    layerB: p
+    mappings:
+      - "[gender:masc] <> [p=M & m=M]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	input := `{"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"}`
+	req := httptest.NewRequest(http.MethodPost, "/test-response-mapper/response?dir=atob&rewrites=false",
+		bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+}
+
+func TestMultipleListsMixedRewritesResolution(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+rewrites: true
+lists:
+  - id: mapper-inherits
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+  - id: mapper-overrides-off
+    foundryA: stts
+    layerA: p
+    foundryB: upos
+    layerB: p
+    rewrites: false
+    mappings:
+      - "[DET] <> [PRON]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	// mapper-inherits should have rewrites (inherits global true)
+	assert.True(t, hasRewritesInResponse(t, app, "/mapper-inherits/query?dir=atob"),
+		"mapper-inherits should have rewrites via global default")
+
+	// mapper-overrides-off has per-list rewrites=false
+	input := `{
+		"@type": "koral:token",
+		"wrap": {
+			"@type": "koral:term",
+			"foundry": "stts",
+			"key": "DET",
+			"layer": "p",
+			"match": "match:eq"
+		}
+	}`
+	req := httptest.NewRequest(http.MethodPost, "/mapper-overrides-off/query?dir=atob",
+		bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	var result map[string]any
+	err = json.NewDecoder(resp.Body).Decode(&result)
+	require.NoError(t, err)
+
+	wrap := result["wrap"].(map[string]any)
+	_, hasRewrites := wrap["rewrites"]
+	assert.False(t, hasRewrites,
+		"mapper-overrides-off should NOT have rewrites (per-list false overrides global true)")
+}