Add configurable Kalamar integration

Change-Id: Ic07423dd7cc605509a364154bf4f37e4c13dc0d1
diff --git a/config/config.go b/config/config.go
index 6bafaad..128c063 100644
--- a/config/config.go
+++ b/config/config.go
@@ -9,6 +9,11 @@
 	"gopkg.in/yaml.v3"
 )
 
+const (
+	defaultServer = "https://korap.ids-mannheim.de/"
+	defaultSDK    = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js"
+)
+
 // MappingRule represents a single mapping rule in the configuration
 type MappingRule string
 
@@ -22,13 +27,15 @@
 	Mappings []MappingRule `yaml:"mappings"`
 }
 
-// MappingLists represents the root configuration containing multiple mapping lists
-type MappingLists struct {
-	Lists []MappingList
+// MappingConfig represents the root configuration containing multiple mapping lists
+type MappingConfig struct {
+	SDK    string        `yaml:"sdk,omitempty"`
+	Server string        `yaml:"server,omitempty"`
+	Lists  []MappingList `yaml:"lists,omitempty"`
 }
 
 // LoadConfig loads a YAML configuration file and returns a Config object
-func LoadConfig(filename string) (*MappingLists, error) {
+func LoadConfig(filename string) (*MappingConfig, error) {
 	data, err := os.ReadFile(filename)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -39,37 +46,71 @@
 		return nil, fmt.Errorf("EOF: config file is empty")
 	}
 
+	// Try to unmarshal as new format first (object with optional sdk/server and lists)
+	var config MappingConfig
+	if err := yaml.Unmarshal(data, &config); err == nil && len(config.Lists) > 0 {
+		// Successfully parsed as new format with lists field
+		if err := validateMappingLists(config.Lists); err != nil {
+			return nil, err
+		}
+		// Apply defaults if not specified
+		applyDefaults(&config)
+		return &config, nil
+	}
+
+	// Fall back to old format (direct list)
 	var lists []MappingList
 	if err := yaml.Unmarshal(data, &lists); err != nil {
 		return nil, fmt.Errorf("failed to parse YAML: %w", err)
 	}
 
+	if err := validateMappingLists(lists); err != nil {
+		return nil, err
+	}
+
+	config = MappingConfig{Lists: lists}
+	// Apply defaults if not specified
+	applyDefaults(&config)
+	return &config, nil
+}
+
+// applyDefaults sets default values for SDK and Server if they are empty
+func applyDefaults(config *MappingConfig) {
+	if config.SDK == "" {
+		config.SDK = defaultSDK
+	}
+	if config.Server == "" {
+		config.Server = defaultServer
+	}
+}
+
+// validateMappingLists validates a slice of mapping lists
+func validateMappingLists(lists []MappingList) error {
 	// Validate the configuration
 	seenIDs := make(map[string]bool)
 	for i, list := range lists {
 		if list.ID == "" {
-			return nil, fmt.Errorf("mapping list at index %d is missing an ID", i)
+			return fmt.Errorf("mapping list at index %d is missing an ID", i)
 		}
 
 		// Check for duplicate IDs
 		if seenIDs[list.ID] {
-			return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
+			return fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
 		}
 		seenIDs[list.ID] = true
 
 		if len(list.Mappings) == 0 {
-			return nil, fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
+			return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
 		}
 
 		// Validate each mapping rule
 		for j, rule := range list.Mappings {
 			if rule == "" {
-				return nil, fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
+				return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
 			}
 		}
 	}
-
-	return &MappingLists{Lists: lists}, nil
+	return nil
 }
 
 // ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
diff --git a/config/config_test.go b/config/config_test.go
index 9dc64ca..6519f82 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -574,3 +574,120 @@
 		assert.Equal(t, expectedValue, pronTypeTerm.Key)
 	}
 }
+
+func TestConfigWithSdkAndServer(t *testing.T) {
+	tests := []struct {
+		name           string
+		content        string
+		expectedSDK    string
+		expectedServer string
+		wantErr        bool
+	}{
+		{
+			name: "Configuration with SDK and Server values",
+			content: `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: test-mapper
+  foundryA: opennlp
+  layerA: p
+  foundryB: upos
+  layerB: p
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://custom.example.com/sdk.js",
+			expectedServer: "https://custom.example.com/",
+			wantErr:        false,
+		},
+		{
+			name: "Configuration with only SDK value",
+			content: `
+sdk: "https://custom.example.com/sdk.js"
+lists:
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://custom.example.com/sdk.js",
+			expectedServer: "https://korap.ids-mannheim.de/", // default applied
+			wantErr:        false,
+		},
+		{
+			name: "Configuration with only Server value",
+			content: `
+server: "https://custom.example.com/"
+lists:
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // default applied
+			expectedServer: "https://custom.example.com/",
+			wantErr:        false,
+		},
+		{
+			name: "Configuration without SDK and Server (old format with defaults applied)",
+			content: `
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // default applied
+			expectedServer: "https://korap.ids-mannheim.de/",                          // default applied
+			wantErr:        false,
+		},
+		{
+			name: "Configuration with lists field explicitly",
+			content: `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: test-mapper-1
+  mappings:
+    - "[A] <> [B]"
+- id: test-mapper-2
+  mappings:
+    - "[C] <> [D]"
+`,
+			expectedSDK:    "https://custom.example.com/sdk.js",
+			expectedServer: "https://custom.example.com/",
+			wantErr:        false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tmpfile, err := os.CreateTemp("", "config-*.yaml")
+			require.NoError(t, err)
+			defer os.Remove(tmpfile.Name())
+
+			_, err = tmpfile.WriteString(tt.content)
+			require.NoError(t, err)
+			err = tmpfile.Close()
+			require.NoError(t, err)
+
+			config, err := LoadConfig(tmpfile.Name())
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+
+			require.NoError(t, err)
+			require.NotNil(t, config)
+
+			// Check SDK and Server values
+			assert.Equal(t, tt.expectedSDK, config.SDK)
+			assert.Equal(t, tt.expectedServer, config.Server)
+
+			// Ensure lists are still loaded correctly
+			require.Greater(t, len(config.Lists), 0)
+
+			// Verify first mapping list
+			firstList := config.Lists[0]
+			assert.NotEmpty(t, firstList.ID)
+			assert.Greater(t, len(firstList.Mappings), 0)
+		})
+	}
+}