Support loading multiple mapping files

Change-Id: I3c6caaa3c4c3434dfacfb842f0407250b5e980f0
diff --git a/cmd/termmapper/main.go b/cmd/termmapper/main.go
index ec8b93d..2a2f7e3 100644
--- a/cmd/termmapper/main.go
+++ b/cmd/termmapper/main.go
@@ -21,9 +21,10 @@
 )
 
 type appConfig struct {
-	Port     int    `kong:"short='p',default='8080',help='Port to listen on'"`
-	Config   string `kong:"short='c',required,help='YAML configuration file containing mapping directives'"`
-	LogLevel string `kong:"short='l',default='info',help='Log level (debug, info, warn, error)'"`
+	Port     int      `kong:"short='p',default='8080',help='Port to listen on'"`
+	Config   string   `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
+	Mappings []string `kong:"short='m',help='Individual YAML mapping files to load'"`
+	LogLevel string   `kong:"short='l',default='info',help='Log level (debug, info, warn, error)'"`
 }
 
 // TemplateData holds data for the Kalamar plugin template
@@ -73,11 +74,16 @@
 	// Parse command line flags
 	cfg := parseConfig()
 
+	// Validate command line arguments
+	if cfg.Config == "" && len(cfg.Mappings) == 0 {
+		log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
+	}
+
 	// Set up logging
 	setupLogger(cfg.LogLevel)
 
-	// Load configuration file
-	yamlConfig, err := config.LoadConfig(cfg.Config)
+	// Load configuration from multiple sources
+	yamlConfig, err := config.LoadFromSources(cfg.Config, cfg.Mappings)
 	if err != nil {
 		log.Fatal().Err(err).Msg("Failed to load configuration")
 	}
@@ -293,8 +299,8 @@
 
 		    <dt><tt><strong>GET</strong> /:map</tt></dt>
             <dd><small>Kalamar integration</small></dd>
-			
-            <dt><tt><strong>POST</strong> /:map/query</tt></dt>
+
+			<dt><tt><strong>POST</strong> /:map/query</tt></dt>
             <dd><small>Transform JSON query objects using term mapping rules</small></dd>
 			
         </dl>
diff --git a/cmd/termmapper/main_test.go b/cmd/termmapper/main_test.go
index 292d3d9..c045e0b 100644
--- a/cmd/termmapper/main_test.go
+++ b/cmd/termmapper/main_test.go
@@ -6,6 +6,7 @@
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 
 	tmconfig "github.com/KorAP/KoralPipe-TermMapper/config"
@@ -385,3 +386,192 @@
 		})
 	}
 }
+
+func TestMultipleMappingFiles(t *testing.T) {
+	// Create test mapping files
+	mappingFile1Content := `
+id: test-mapper-1
+foundryA: opennlp
+layerA: p
+foundryB: upos
+layerB: p
+mappings:
+  - "[PIDAT] <> [DET & AdjType=Pdt]"
+  - "[PAV] <> [ADV & PronType=Dem]"
+`
+	mappingFile1, err := os.CreateTemp("", "mapping1-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(mappingFile1.Name())
+
+	_, err = mappingFile1.WriteString(mappingFile1Content)
+	require.NoError(t, err)
+	err = mappingFile1.Close()
+	require.NoError(t, err)
+
+	mappingFile2Content := `
+id: test-mapper-2
+foundryA: stts
+layerA: p
+foundryB: upos
+layerB: p
+mappings:
+  - "[DET] <> [PRON]"
+  - "[ADJ] <> [NOUN]"
+`
+	mappingFile2, err := os.CreateTemp("", "mapping2-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(mappingFile2.Name())
+
+	_, err = mappingFile2.WriteString(mappingFile2Content)
+	require.NoError(t, err)
+	err = mappingFile2.Close()
+	require.NoError(t, err)
+
+	// Load configuration using multiple mapping files
+	config, err := tmconfig.LoadFromSources("", []string{mappingFile1.Name(), mappingFile2.Name()})
+	require.NoError(t, err)
+
+	// Create mapper
+	m, err := mapper.NewMapper(config.Lists)
+	require.NoError(t, err)
+
+	// Create fiber app
+	app := fiber.New()
+	setupRoutes(app, m, config)
+
+	// Test that both mappers work
+	testCases := []struct {
+		name        string
+		mapID       string
+		input       string
+		expectGroup bool
+		expectedKey string
+	}{
+		{
+			name:  "test-mapper-1 with complex mapping",
+			mapID: "test-mapper-1",
+			input: `{
+				"@type": "koral:token",
+				"wrap": {
+					"@type": "koral:term",
+					"foundry": "opennlp",
+					"key": "PIDAT",
+					"layer": "p",
+					"match": "match:eq"
+				}
+			}`,
+			expectGroup: true,  // This mapping creates a termGroup because of "&"
+			expectedKey: "DET", // The first operand should be DET
+		},
+		{
+			name:  "test-mapper-2 with simple mapping",
+			mapID: "test-mapper-2",
+			input: `{
+				"@type": "koral:token",
+				"wrap": {
+					"@type": "koral:term",
+					"foundry": "stts",
+					"key": "DET",
+					"layer": "p",
+					"match": "match:eq"
+				}
+			}`,
+			expectGroup: false, // This mapping creates a simple term
+			expectedKey: "PRON",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/"+tc.mapID+"/query?dir=atob", bytes.NewBufferString(tc.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)
+
+			var result map[string]interface{}
+			err = json.NewDecoder(resp.Body).Decode(&result)
+			require.NoError(t, err)
+
+			// Check that the mapping was applied
+			wrap := result["wrap"].(map[string]interface{})
+			if tc.expectGroup {
+				// For complex mappings, check the first operand
+				assert.Equal(t, "koral:termGroup", wrap["@type"])
+				operands := wrap["operands"].([]interface{})
+				require.Greater(t, len(operands), 0)
+				firstOperand := operands[0].(map[string]interface{})
+				assert.Equal(t, tc.expectedKey, firstOperand["key"])
+			} else {
+				// For simple mappings, check the key directly
+				assert.Equal(t, "koral:term", wrap["@type"])
+				assert.Equal(t, tc.expectedKey, wrap["key"])
+			}
+		})
+	}
+}
+
+func TestCombinedConfigAndMappingFiles(t *testing.T) {
+	// Create main config file
+	mainConfigContent := `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: main-mapper
+  foundryA: opennlp
+  layerA: p
+  mappings:
+    - "[A] <> [B]"
+`
+	mainConfigFile, err := os.CreateTemp("", "main-config-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(mainConfigFile.Name())
+
+	_, err = mainConfigFile.WriteString(mainConfigContent)
+	require.NoError(t, err)
+	err = mainConfigFile.Close()
+	require.NoError(t, err)
+
+	// Create individual mapping file
+	mappingFileContent := `
+id: additional-mapper
+foundryA: stts
+layerA: p
+mappings:
+  - "[C] <> [D]"
+`
+	mappingFile, err := os.CreateTemp("", "mapping-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(mappingFile.Name())
+
+	_, err = mappingFile.WriteString(mappingFileContent)
+	require.NoError(t, err)
+	err = mappingFile.Close()
+	require.NoError(t, err)
+
+	// Load configuration from both sources
+	config, err := tmconfig.LoadFromSources(mainConfigFile.Name(), []string{mappingFile.Name()})
+	require.NoError(t, err)
+
+	// Verify that both mappers are loaded
+	require.Len(t, config.Lists, 2)
+
+	ids := make([]string, len(config.Lists))
+	for i, list := range config.Lists {
+		ids[i] = list.ID
+	}
+	assert.Contains(t, ids, "main-mapper")
+	assert.Contains(t, ids, "additional-mapper")
+
+	// Verify custom SDK and server are preserved from main config
+	assert.Equal(t, "https://custom.example.com/sdk.js", config.SDK)
+	assert.Equal(t, "https://custom.example.com/", config.Server)
+
+	// Create mapper and test it works
+	m, err := mapper.NewMapper(config.Lists)
+	require.NoError(t, err)
+	require.NotNil(t, m)
+}