Support loading multiple mapping files

Change-Id: I3c6caaa3c4c3434dfacfb842f0407250b5e980f0
diff --git a/config/config.go b/config/config.go
index 128c063..46d6639 100644
--- a/config/config.go
+++ b/config/config.go
@@ -34,44 +34,107 @@
 	Lists  []MappingList `yaml:"lists,omitempty"`
 }
 
-// LoadConfig loads a YAML configuration file and returns a Config object
-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)
-	}
+// LoadFromSources loads configuration from multiple sources and merges them:
+// - A main configuration file (optional) containing global settings and lists
+// - Individual mapping files (optional) containing single mapping lists each
+// At least one source must be provided
+func LoadFromSources(configFile string, mappingFiles []string) (*MappingConfig, error) {
+	var allLists []MappingList
+	var globalConfig MappingConfig
 
-	// Check for empty file
-	if len(data) == 0 {
-		return nil, fmt.Errorf("EOF: config file is empty")
-	}
+	// Track seen IDs across all sources to detect duplicates
+	seenIDs := make(map[string]bool)
 
-	// 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
+	// Load main configuration file if provided
+	if configFile != "" {
+		data, err := os.ReadFile(configFile)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read config file '%s': %w", configFile, err)
 		}
-		// Apply defaults if not specified
-		applyDefaults(&config)
-		return &config, nil
+
+		if len(data) == 0 {
+			return nil, fmt.Errorf("EOF: config file '%s' is empty", configFile)
+		}
+
+		// Try to unmarshal as new format first (object with optional sdk/server and lists)
+		if err := yaml.Unmarshal(data, &globalConfig); err == nil && len(globalConfig.Lists) > 0 {
+			// Successfully parsed as new format with lists field
+			for _, list := range globalConfig.Lists {
+				if seenIDs[list.ID] {
+					return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
+				}
+				seenIDs[list.ID] = true
+			}
+			allLists = append(allLists, globalConfig.Lists...)
+		} else {
+			// 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 config file '%s': %w", configFile, err)
+			}
+
+			for _, list := range lists {
+				if seenIDs[list.ID] {
+					return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
+				}
+				seenIDs[list.ID] = true
+			}
+			allLists = append(allLists, lists...)
+			// Clear the lists from globalConfig since we got them from the old format
+			globalConfig.Lists = 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)
+	// Load individual mapping files
+	for _, file := range mappingFiles {
+		data, err := os.ReadFile(file)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read mapping file '%s': %w", file, err)
+		}
+
+		if len(data) == 0 {
+			return nil, fmt.Errorf("EOF: mapping file '%s' is empty", file)
+		}
+
+		var list MappingList
+		if err := yaml.Unmarshal(data, &list); err != nil {
+			return nil, fmt.Errorf("failed to parse YAML mapping file '%s': %w", file, err)
+		}
+
+		if seenIDs[list.ID] {
+			return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
+		}
+		seenIDs[list.ID] = true
+		allLists = append(allLists, list)
 	}
 
-	if err := validateMappingLists(lists); err != nil {
+	// Ensure we have at least some configuration
+	if len(allLists) == 0 {
+		return nil, fmt.Errorf("no mapping lists found: provide either a config file (-c) with lists or mapping files (-m)")
+	}
+
+	// Validate all mapping lists
+	if err := validateMappingLists(allLists); err != nil {
 		return nil, err
 	}
 
-	config = MappingConfig{Lists: lists}
+	// Create final configuration
+	result := &MappingConfig{
+		SDK:    globalConfig.SDK,
+		Server: globalConfig.Server,
+		Lists:  allLists,
+	}
+
 	// Apply defaults if not specified
-	applyDefaults(&config)
-	return &config, nil
+	applyDefaults(result)
+
+	return result, nil
+}
+
+// LoadConfig loads a YAML configuration file and returns a Config object
+// Deprecated: Use LoadFromSources for new code
+func LoadConfig(filename string) (*MappingConfig, error) {
+	return LoadFromSources(filename, nil)
 }
 
 // applyDefaults sets default values for SDK and Server if they are empty
diff --git a/config/config_test.go b/config/config_test.go
index 6519f82..f2678fb 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -691,3 +691,181 @@
 		})
 	}
 }
+
+func TestLoadFromSources(t *testing.T) {
+	// Create main config file
+	mainConfigContent := `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: main-mapper
+  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 files
+	mappingFile1Content := `
+id: mapper-1
+foundryA: opennlp
+layerA: p
+mappings:
+  - "[C] <> [D]"
+`
+	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: mapper-2
+foundryB: upos
+layerB: p
+mappings:
+  - "[E] <> [F]"
+`
+	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)
+
+	tests := []struct {
+		name         string
+		configFile   string
+		mappingFiles []string
+		wantErr      bool
+		expectedIDs  []string
+	}{
+		{
+			name:         "Main config only",
+			configFile:   mainConfigFile.Name(),
+			mappingFiles: []string{},
+			wantErr:      false,
+			expectedIDs:  []string{"main-mapper"},
+		},
+		{
+			name:         "Mapping files only",
+			configFile:   "",
+			mappingFiles: []string{mappingFile1.Name(), mappingFile2.Name()},
+			wantErr:      false,
+			expectedIDs:  []string{"mapper-1", "mapper-2"},
+		},
+		{
+			name:         "Main config and mapping files",
+			configFile:   mainConfigFile.Name(),
+			mappingFiles: []string{mappingFile1.Name(), mappingFile2.Name()},
+			wantErr:      false,
+			expectedIDs:  []string{"main-mapper", "mapper-1", "mapper-2"},
+		},
+		{
+			name:         "No configuration sources",
+			configFile:   "",
+			mappingFiles: []string{},
+			wantErr:      true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			config, err := LoadFromSources(tt.configFile, tt.mappingFiles)
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+
+			require.NoError(t, err)
+			require.NotNil(t, config)
+
+			// Check that all expected mapping IDs are present
+			require.Len(t, config.Lists, len(tt.expectedIDs))
+			actualIDs := make([]string, len(config.Lists))
+			for i, list := range config.Lists {
+				actualIDs[i] = list.ID
+			}
+			for _, expectedID := range tt.expectedIDs {
+				assert.Contains(t, actualIDs, expectedID)
+			}
+
+			// Check that SDK and Server are set (either from config or defaults)
+			assert.NotEmpty(t, config.SDK)
+			assert.NotEmpty(t, config.Server)
+		})
+	}
+}
+
+func TestLoadFromSourcesWithDefaults(t *testing.T) {
+	// Test that defaults are applied when loading only mapping files
+	mappingFileContent := `
+id: test-mapper
+mappings:
+  - "[A] <> [B]"
+`
+	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)
+
+	config, err := LoadFromSources("", []string{mappingFile.Name()})
+	require.NoError(t, err)
+
+	// Check that defaults are applied
+	assert.Equal(t, defaultSDK, config.SDK)
+	assert.Equal(t, defaultServer, config.Server)
+	require.Len(t, config.Lists, 1)
+	assert.Equal(t, "test-mapper", config.Lists[0].ID)
+}
+
+func TestLoadFromSourcesDuplicateIDs(t *testing.T) {
+	// Create config with duplicate IDs across sources
+	configContent := `
+lists:
+- id: duplicate-id
+  mappings:
+    - "[A] <> [B]"
+`
+	configFile, err := os.CreateTemp("", "config-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(configFile.Name())
+
+	_, err = configFile.WriteString(configContent)
+	require.NoError(t, err)
+	err = configFile.Close()
+	require.NoError(t, err)
+
+	mappingContent := `
+id: duplicate-id
+mappings:
+  - "[C] <> [D]"
+`
+	mappingFile, err := os.CreateTemp("", "mapping-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(mappingFile.Name())
+
+	_, err = mappingFile.WriteString(mappingContent)
+	require.NoError(t, err)
+	err = mappingFile.Close()
+	require.NoError(t, err)
+
+	_, err = LoadFromSources(configFile.Name(), []string{mappingFile.Name()})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "duplicate mapping list ID found: duplicate-id")
+}