Add koral:rewrite to query and corpus transformations

Change-Id: I97e3050d39b936256616bdf46203a784de6a3414
diff --git a/ast/rewrite_test.go b/ast/rewrite_test.go
index 63f5e53..4fe0ffb 100644
--- a/ast/rewrite_test.go
+++ b/ast/rewrite_test.go
@@ -197,6 +197,176 @@
 	assert.Equal(t, "legacy-origin", rewrites[1].Src)
 }
 
+func TestRewriteableInterface(t *testing.T) {
+	t.Run("Term implements Rewriteable", func(t *testing.T) {
+		term := &Term{Foundry: "opennlp", Key: "DET", Layer: "p", Match: MatchEqual}
+
+		var r Rewriteable = term
+		assert.Nil(t, r.GetRewrites())
+
+		rewrites := []Rewrite{{Editor: "test", Scope: "foundry"}}
+		r.SetRewrites(rewrites)
+		assert.Equal(t, rewrites, r.GetRewrites())
+		assert.Equal(t, rewrites, term.Rewrites)
+	})
+
+	t.Run("TermGroup implements Rewriteable", func(t *testing.T) {
+		tg := &TermGroup{
+			Operands: []Node{&Term{Key: "A", Match: MatchEqual}},
+			Relation: AndRelation,
+		}
+
+		var r Rewriteable = tg
+		assert.Nil(t, r.GetRewrites())
+
+		rewrites := []Rewrite{{Editor: "editor", Scope: "layer", Original: "old"}}
+		r.SetRewrites(rewrites)
+		assert.Equal(t, rewrites, r.GetRewrites())
+		assert.Equal(t, rewrites, tg.Rewrites)
+	})
+
+	t.Run("Token implements Rewriteable", func(t *testing.T) {
+		tok := &Token{Wrap: &Term{Key: "X", Match: MatchEqual}}
+
+		var r Rewriteable = tok
+		assert.Nil(t, r.GetRewrites())
+
+		rewrites := []Rewrite{{Editor: "mapper", Operation: "op"}}
+		r.SetRewrites(rewrites)
+		assert.Equal(t, rewrites, r.GetRewrites())
+		assert.Equal(t, rewrites, tok.Rewrites)
+	})
+
+	t.Run("SetRewrites to nil clears slice", func(t *testing.T) {
+		term := &Term{
+			Key:      "DET",
+			Match:    MatchEqual,
+			Rewrites: []Rewrite{{Editor: "x"}},
+		}
+		term.SetRewrites(nil)
+		assert.Nil(t, term.GetRewrites())
+	})
+}
+
+func TestAppendRewrite(t *testing.T) {
+	t.Run("Append to Term", func(t *testing.T) {
+		term := &Term{Key: "DET", Match: MatchEqual}
+		rw := Rewrite{Editor: "Koral-Mapper", Scope: "foundry", Original: "opennlp"}
+
+		AppendRewrite(term, rw)
+		assert.Equal(t, []Rewrite{rw}, term.Rewrites)
+
+		rw2 := Rewrite{Editor: "Koral-Mapper", Scope: "key", Original: "PIDAT"}
+		AppendRewrite(term, rw2)
+		assert.Equal(t, []Rewrite{rw, rw2}, term.Rewrites)
+	})
+
+	t.Run("Append to TermGroup", func(t *testing.T) {
+		tg := &TermGroup{
+			Operands: []Node{&Term{Key: "A", Match: MatchEqual}},
+			Relation: AndRelation,
+		}
+		rw := Rewrite{Editor: "editor", Original: "orig"}
+		AppendRewrite(tg, rw)
+		assert.Equal(t, []Rewrite{rw}, tg.Rewrites)
+	})
+
+	t.Run("Append to Token", func(t *testing.T) {
+		tok := &Token{Wrap: &Term{Key: "X", Match: MatchEqual}}
+		rw := Rewrite{Editor: "ed"}
+		AppendRewrite(tok, rw)
+		assert.Equal(t, []Rewrite{rw}, tok.Rewrites)
+	})
+
+	t.Run("Append to non-Rewriteable is no-op", func(t *testing.T) {
+		catchall := &CatchallNode{NodeType: "koral:span"}
+		rw := Rewrite{Editor: "test"}
+		AppendRewrite(catchall, rw)
+		// CatchallNode doesn't implement Rewriteable, so nothing happens
+	})
+
+	t.Run("Append to nil is no-op", func(t *testing.T) {
+		assert.NotPanics(t, func() {
+			AppendRewrite(nil, Rewrite{Editor: "x"})
+		})
+	})
+}
+
+func TestStripRewrites(t *testing.T) {
+	t.Run("Strips from Term", func(t *testing.T) {
+		term := &Term{
+			Key:      "DET",
+			Match:    MatchEqual,
+			Rewrites: []Rewrite{{Editor: "a"}, {Editor: "b"}},
+		}
+		StripRewrites(term)
+		assert.Nil(t, term.Rewrites)
+	})
+
+	t.Run("Strips from Token and its Wrap", func(t *testing.T) {
+		tok := &Token{
+			Wrap: &Term{
+				Key:      "DET",
+				Match:    MatchEqual,
+				Rewrites: []Rewrite{{Editor: "inner"}},
+			},
+			Rewrites: []Rewrite{{Editor: "outer"}},
+		}
+		StripRewrites(tok)
+		assert.Nil(t, tok.Rewrites)
+		assert.Nil(t, tok.Wrap.(*Term).Rewrites)
+	})
+
+	t.Run("Strips from TermGroup and all operands", func(t *testing.T) {
+		tg := &TermGroup{
+			Operands: []Node{
+				&Term{Key: "A", Match: MatchEqual, Rewrites: []Rewrite{{Editor: "e1"}}},
+				&Term{Key: "B", Match: MatchEqual, Rewrites: []Rewrite{{Editor: "e2"}}},
+			},
+			Relation: AndRelation,
+			Rewrites: []Rewrite{{Editor: "group"}},
+		}
+		StripRewrites(tg)
+		assert.Nil(t, tg.Rewrites)
+		assert.Nil(t, tg.Operands[0].(*Term).Rewrites)
+		assert.Nil(t, tg.Operands[1].(*Term).Rewrites)
+	})
+
+	t.Run("Strips recursively from CatchallNode", func(t *testing.T) {
+		catchall := &CatchallNode{
+			NodeType: "koral:group",
+			Wrap: &Term{
+				Key:      "W",
+				Match:    MatchEqual,
+				Rewrites: []Rewrite{{Editor: "wrap-ed"}},
+			},
+			Operands: []Node{
+				&Token{
+					Wrap:     &Term{Key: "X", Match: MatchEqual, Rewrites: []Rewrite{{Editor: "deep"}}},
+					Rewrites: []Rewrite{{Editor: "tok"}},
+				},
+			},
+		}
+		StripRewrites(catchall)
+		assert.Nil(t, catchall.Wrap.(*Term).Rewrites)
+		tok := catchall.Operands[0].(*Token)
+		assert.Nil(t, tok.Rewrites)
+		assert.Nil(t, tok.Wrap.(*Term).Rewrites)
+	})
+
+	t.Run("Nil node does not panic", func(t *testing.T) {
+		assert.NotPanics(t, func() {
+			StripRewrites(nil)
+		})
+	})
+
+	t.Run("Already empty rewrites stays nil", func(t *testing.T) {
+		term := &Term{Key: "DET", Match: MatchEqual}
+		StripRewrites(term)
+		assert.Nil(t, term.Rewrites)
+	})
+}
+
 func TestRewriteMarshalJSON(t *testing.T) {
 	// Test that marshaling works correctly and maintains the modern field names
 	rewrite := Rewrite{