Add mapping-configuration string builder and parser

Change-Id: I78833fe8666d501846e7ce05d13097e02701a5f4
diff --git a/cmd/koralmapper/cfgparam.go b/cmd/koralmapper/cfgparam.go
new file mode 100644
index 0000000..dc00da5
--- /dev/null
+++ b/cmd/koralmapper/cfgparam.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/KorAP/Koral-Mapper/config"
+)
+
+// CascadeEntry represents a single mapping configuration parsed from
+// the cfg URL parameter. After parsing, empty override fields are
+// merged with the YAML defaults from the corresponding MappingList.
+type CascadeEntry struct {
+	ID        string
+	Direction string
+	FoundryA  string
+	LayerA    string
+	FoundryB  string
+	LayerB    string
+}
+
+// ParseCfgParam parses the compact cfg URL parameter into a slice of
+// CascadeEntry structs. Empty override fields are merged with YAML
+// defaults from the matching MappingList.
+//
+// Format: entry (";" entry)*
+//
+//	entry = id ":" dir [ ":" foundryA ":" layerA ":" foundryB ":" layerB ]
+//
+// An entry has either 2 fields (all foundry/layer use defaults) or
+// 6 fields (explicit values, empty means use default).
+func ParseCfgParam(raw string, lists []config.MappingList) ([]CascadeEntry, error) {
+	if raw == "" {
+		return nil, nil
+	}
+
+	listsByID := make(map[string]*config.MappingList, len(lists))
+	for i := range lists {
+		listsByID[lists[i].ID] = &lists[i]
+	}
+
+	parts := strings.Split(raw, ";")
+	result := make([]CascadeEntry, 0, len(parts))
+
+	for _, part := range parts {
+		fields := strings.Split(part, ":")
+		n := len(fields)
+
+		if n != 2 && n != 6 {
+			return nil, fmt.Errorf("invalid entry %q: expected 2 or 6 colon-separated fields, got %d", part, n)
+		}
+
+		id := fields[0]
+		dir := fields[1]
+
+		if dir != "atob" && dir != "btoa" {
+			return nil, fmt.Errorf("invalid direction %q in entry %q", dir, part)
+		}
+
+		list, ok := listsByID[id]
+		if !ok {
+			return nil, fmt.Errorf("unknown mapping ID %q", id)
+		}
+
+		ce := CascadeEntry{
+			ID:        id,
+			Direction: dir,
+		}
+
+		if n == 6 {
+			ce.FoundryA = fields[2]
+			ce.LayerA = fields[3]
+			ce.FoundryB = fields[4]
+			ce.LayerB = fields[5]
+		}
+
+		if ce.FoundryA == "" {
+			ce.FoundryA = list.FoundryA
+		}
+		if ce.LayerA == "" {
+			ce.LayerA = list.LayerA
+		}
+		if ce.FoundryB == "" {
+			ce.FoundryB = list.FoundryB
+		}
+		if ce.LayerB == "" {
+			ce.LayerB = list.LayerB
+		}
+
+		result = append(result, ce)
+	}
+
+	return result, nil
+}
+
+// BuildCfgParam serialises a slice of CascadeEntry back to the compact
+// cfg string format. Entries with all foundry/layer fields empty use
+// the short 2-field format (id:dir). Entries with any non-empty
+// foundry/layer field use the full 6-field format.
+func BuildCfgParam(entries []CascadeEntry) string {
+	if len(entries) == 0 {
+		return ""
+	}
+
+	parts := make([]string, len(entries))
+	for i, e := range entries {
+		if e.FoundryA == "" && e.LayerA == "" && e.FoundryB == "" && e.LayerB == "" {
+			parts[i] = e.ID + ":" + e.Direction
+		} else {
+			parts[i] = e.ID + ":" + e.Direction + ":" + e.FoundryA + ":" + e.LayerA + ":" + e.FoundryB + ":" + e.LayerB
+		}
+	}
+
+	return strings.Join(parts, ";")
+}