Reject identical source/target in annotation and corpus mappings

Change-Id: I09a410e5d42392680c1ac1c5c9928e3a37aca0cc
diff --git a/mapper/mapper.go b/mapper/mapper.go
index 2085093..f72c5c5 100644
--- a/mapper/mapper.go
+++ b/mapper/mapper.go
@@ -91,6 +91,57 @@
 	AddRewrites bool
 }
 
+// validateEffectiveOptions checks that the resolved source and target
+// identifiers are not identical, which would cause an infinite mapping loop.
+// For annotation mappings it compares the effective foundry+layer pair;
+// for corpus mappings it compares the effective field names.
+// The effective value is: query-parameter override if non-empty, otherwise
+// the YAML list default.
+func (m *Mapper) validateEffectiveOptions(mappingID string, opts MappingOptions) error {
+	list, exists := m.mappingLists[mappingID]
+	if !exists {
+		return nil // will be caught later
+	}
+
+	if list.IsCorpus() {
+		effFieldA := opts.FieldA
+		if effFieldA == "" {
+			effFieldA = list.FieldA
+		}
+		effFieldB := opts.FieldB
+		if effFieldB == "" {
+			effFieldB = list.FieldB
+		}
+		if effFieldA != "" && effFieldA == effFieldB {
+			return fmt.Errorf("identical source and target field (fieldA == fieldB == %q) in mapping list '%s': this would cause an infinite mapping loop", effFieldA, mappingID)
+		}
+		return nil
+	}
+
+	effFoundryA := opts.FoundryA
+	if effFoundryA == "" {
+		effFoundryA = list.FoundryA
+	}
+	effLayerA := opts.LayerA
+	if effLayerA == "" {
+		effLayerA = list.LayerA
+	}
+	effFoundryB := opts.FoundryB
+	if effFoundryB == "" {
+		effFoundryB = list.FoundryB
+	}
+	effLayerB := opts.LayerB
+	if effLayerB == "" {
+		effLayerB = list.LayerB
+	}
+
+	if effFoundryA != "" && effFoundryA == effFoundryB && effLayerA == effLayerB {
+		return fmt.Errorf("identical source and target (foundryA/layerA == foundryB/layerB == %q/%q) in mapping list '%s': this would cause an infinite mapping loop", effFoundryA, effLayerA, mappingID)
+	}
+
+	return nil
+}
+
 // CascadeQueryMappings applies multiple mapping lists sequentially,
 // feeding the output of each into the next. orderedIDs and
 // perMappingOpts must have the same length. An empty list returns