Files
beads/internal/storage/sqlite/collision_test.go
Steve Yegge 25644d9717 Cache compiled regexes in ID replacement for 1.9x performance boost
Implements bd-27: Cache compiled regexes in replaceIDReferences for performance

Problem:
replaceIDReferences() was compiling regex patterns on every call. With 100
issues and 10 ID mappings, that resulted in 4,000 regex compilations (100
issues × 4 text fields × 10 ID mappings).

Solution:
- Added buildReplacementCache() to pre-compile all regexes once
- Added replaceIDReferencesWithCache() to reuse compiled regexes
- Updated updateReferences() to build cache once and reuse for all issues
- Kept replaceIDReferences() for backward compatibility (calls cached version)

Performance Results (from benchmarks):
Single text:
- 1.33x faster (26,162 ns → 19,641 ns)
- 68% less memory (25,769 B → 8,241 B)
- 80% fewer allocations (278 → 55)

Real-world (400 texts, 10 mappings):
- 1.89x faster (5.1ms → 2.7ms)
- 90% less memory (7.7 MB → 0.8 MB)
- 86% fewer allocations (104,112 → 14,801)

Tests:
- All existing tests pass
- Added 3 benchmark tests demonstrating improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 23:50:48 -07:00

1105 lines
29 KiB
Go

package sqlite
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestDetectCollisions(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "collision-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Setup: Create some existing issues in the database
existingIssue1 := &types.Issue{
ID: "bd-1",
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
existingIssue2 := &types.Issue{
ID: "bd-2",
Title: "Existing issue 2",
Description: "Another existing issue",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
}
if err := store.CreateIssue(ctx, existingIssue1, "test"); err != nil {
t.Fatalf("failed to create existing issue 1: %v", err)
}
if err := store.CreateIssue(ctx, existingIssue2, "test"); err != nil {
t.Fatalf("failed to create existing issue 2: %v", err)
}
// Test cases
tests := []struct {
name string
incomingIssues []*types.Issue
expectedExact int
expectedCollision int
expectedNew int
checkCollisions func(t *testing.T, collisions []*CollisionDetail)
}{
{
name: "exact match - idempotent import",
incomingIssues: []*types.Issue{
{
ID: "bd-1",
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
},
expectedExact: 1,
expectedCollision: 0,
expectedNew: 0,
},
{
name: "new issue - doesn't exist in DB",
incomingIssues: []*types.Issue{
{
ID: "bd-100",
Title: "Brand new issue",
Description: "This doesn't exist yet",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeFeature,
},
},
expectedExact: 0,
expectedCollision: 0,
expectedNew: 1,
},
{
name: "collision - same ID, different title",
incomingIssues: []*types.Issue{
{
ID: "bd-1",
Title: "Modified title",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
},
expectedExact: 0,
expectedCollision: 1,
expectedNew: 0,
checkCollisions: func(t *testing.T, collisions []*CollisionDetail) {
if len(collisions) != 1 {
t.Fatalf("expected 1 collision, got %d", len(collisions))
}
if collisions[0].ID != "bd-1" {
t.Errorf("expected collision ID bd-1, got %s", collisions[0].ID)
}
if len(collisions[0].ConflictingFields) != 1 {
t.Errorf("expected 1 conflicting field, got %d", len(collisions[0].ConflictingFields))
}
if collisions[0].ConflictingFields[0] != "title" {
t.Errorf("expected conflicting field 'title', got %s", collisions[0].ConflictingFields[0])
}
},
},
{
name: "collision - multiple fields differ",
incomingIssues: []*types.Issue{
{
ID: "bd-2",
Title: "Changed title",
Description: "Changed description",
Status: types.StatusClosed,
Priority: 3,
IssueType: types.TypeFeature,
},
},
expectedExact: 0,
expectedCollision: 1,
expectedNew: 0,
checkCollisions: func(t *testing.T, collisions []*CollisionDetail) {
if len(collisions) != 1 {
t.Fatalf("expected 1 collision, got %d", len(collisions))
}
// Should have multiple conflicting fields
expectedFields := map[string]bool{
"title": true,
"description": true,
"status": true,
"priority": true,
"issue_type": true,
}
for _, field := range collisions[0].ConflictingFields {
if !expectedFields[field] {
t.Errorf("unexpected conflicting field: %s", field)
}
delete(expectedFields, field)
}
if len(expectedFields) > 0 {
t.Errorf("missing expected conflicting fields: %v", expectedFields)
}
},
},
{
name: "mixed - exact, collision, and new",
incomingIssues: []*types.Issue{
{
// Exact match
ID: "bd-1",
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
{
// Collision
ID: "bd-2",
Title: "Modified issue 2",
Description: "Another existing issue",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
},
{
// New issue
ID: "bd-200",
Title: "New issue",
Description: "This is new",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
},
expectedExact: 1,
expectedCollision: 1,
expectedNew: 1,
},
{
name: "collision - estimated_minutes differs",
incomingIssues: []*types.Issue{
{
ID: "bd-1",
Title: "Existing issue 1",
Description: "This is an existing issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(60),
},
},
expectedExact: 0,
expectedCollision: 1,
expectedNew: 0,
checkCollisions: func(t *testing.T, collisions []*CollisionDetail) {
if len(collisions[0].ConflictingFields) != 1 {
t.Errorf("expected 1 conflicting field, got %d", len(collisions[0].ConflictingFields))
}
if collisions[0].ConflictingFields[0] != "estimated_minutes" {
t.Errorf("expected conflicting field 'estimated_minutes', got %s", collisions[0].ConflictingFields[0])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := DetectCollisions(ctx, store, tt.incomingIssues)
if err != nil {
t.Fatalf("DetectCollisions failed: %v", err)
}
if len(result.ExactMatches) != tt.expectedExact {
t.Errorf("expected %d exact matches, got %d", tt.expectedExact, len(result.ExactMatches))
}
if len(result.Collisions) != tt.expectedCollision {
t.Errorf("expected %d collisions, got %d", tt.expectedCollision, len(result.Collisions))
}
if len(result.NewIssues) != tt.expectedNew {
t.Errorf("expected %d new issues, got %d", tt.expectedNew, len(result.NewIssues))
}
if tt.checkCollisions != nil {
tt.checkCollisions(t, result.Collisions)
}
})
}
}
func TestCompareIssues(t *testing.T) {
tests := []struct {
name string
existing *types.Issue
incoming *types.Issue
expected []string
}{
{
name: "identical issues",
existing: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test desc",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
incoming: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test desc",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
expected: []string{},
},
{
name: "different title",
existing: &types.Issue{
ID: "bd-1",
Title: "Original",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
incoming: &types.Issue{
ID: "bd-1",
Title: "Modified",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
expected: []string{"title"},
},
{
name: "different status and priority",
existing: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
incoming: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusClosed,
Priority: 3,
IssueType: types.TypeTask,
},
expected: []string{"status", "priority"},
},
{
name: "estimated_minutes - both nil",
existing: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: nil,
},
incoming: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: nil,
},
expected: []string{},
},
{
name: "estimated_minutes - existing nil, incoming set",
existing: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: nil,
},
incoming: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(30),
},
expected: []string{"estimated_minutes"},
},
{
name: "estimated_minutes - same values",
existing: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(60),
},
incoming: &types.Issue{
ID: "bd-1",
Title: "Test",
Description: "Test",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
EstimatedMinutes: intPtr(60),
},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conflicts := compareIssues(tt.existing, tt.incoming)
if len(conflicts) != len(tt.expected) {
t.Errorf("expected %d conflicts, got %d: %v", len(tt.expected), len(conflicts), conflicts)
return
}
for i, expected := range tt.expected {
if conflicts[i] != expected {
t.Errorf("conflict[%d]: expected %s, got %s", i, expected, conflicts[i])
}
}
})
}
}
func TestEqualIntPtr(t *testing.T) {
tests := []struct {
name string
a *int
b *int
expected bool
}{
{"both nil", nil, nil, true},
{"a nil, b set", nil, intPtr(5), false},
{"a set, b nil", intPtr(5), nil, false},
{"same values", intPtr(10), intPtr(10), true},
{"different values", intPtr(10), intPtr(20), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := equalIntPtr(tt.a, tt.b)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
// Helper function to create *int from int value
func intPtr(i int) *int {
return &i
}
func TestCountReferences(t *testing.T) {
allIssues := []*types.Issue{
{
ID: "bd-1",
Title: "Issue 1",
Description: "This mentions bd-2 and bd-3",
Design: "Design mentions bd-2 twice: bd-2 and bd-2",
Notes: "Notes mention bd-3",
},
{
ID: "bd-2",
Title: "Issue 2",
Description: "This mentions bd-1",
},
{
ID: "bd-3",
Title: "Issue 3",
Description: "No mentions here",
},
{
ID: "bd-10",
Title: "Issue 10",
Description: "This has bd-100 but not bd-10 itself",
},
}
allDeps := map[string][]*types.Dependency{
"bd-1": {
{IssueID: "bd-1", DependsOnID: "bd-2", Type: types.DepBlocks},
},
"bd-2": {
{IssueID: "bd-2", DependsOnID: "bd-3", Type: types.DepBlocks},
},
}
tests := []struct {
name string
issueID string
expectedCount int
}{
{
name: "bd-1 - one text mention, one dependency",
issueID: "bd-1",
// Text: bd-2's description mentions bd-1 (1)
// Deps: bd-1 → bd-2 (1)
expectedCount: 2,
},
{
name: "bd-2 - multiple text mentions, two dependencies",
issueID: "bd-2",
// Text: bd-1's description mentions bd-2 (1) + bd-1's design mentions bd-2 three times (3) = 4
// (design has: "mentions bd-2" + "bd-2 and" + "bd-2")
// Deps: bd-1 → bd-2 (1) + bd-2 → bd-3 (1) = 2
expectedCount: 6,
},
{
name: "bd-3 - some text mentions, one dependency",
issueID: "bd-3",
// Text: bd-1's description (1) + bd-1's notes (1) = 2
// Deps: bd-2 → bd-3 (1)
expectedCount: 3,
},
{
name: "bd-10 - no mentions (bd-100 doesn't count)",
issueID: "bd-10",
// Text: bd-100 in bd-10's description doesn't match \bbd-10\b = 0
// Deps: none = 0
expectedCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
count, err := countReferences(tt.issueID, allIssues, allDeps)
if err != nil {
t.Fatalf("countReferences failed: %v", err)
}
if count != tt.expectedCount {
t.Errorf("expected count %d, got %d", tt.expectedCount, count)
}
})
}
}
func TestScoreCollisions(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "score-collision-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Setup: Create issues with various reference patterns
issue1 := &types.Issue{
ID: "bd-1",
Title: "Issue 1",
Description: "Depends on bd-2",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue2 := &types.Issue{
ID: "bd-2",
Title: "Issue 2",
Description: "Referenced by bd-1 and bd-3",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "bd-3",
Title: "Issue 3",
Description: "Mentions bd-2 multiple times: bd-2 and bd-2",
Notes: "Also mentions bd-2 here",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue4 := &types.Issue{
ID: "bd-4",
Title: "Issue 4",
Description: "Lonely issue with no references",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
// Create issues in DB
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("failed to create issue1: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("failed to create issue2: %v", err)
}
if err := store.CreateIssue(ctx, issue3, "test"); err != nil {
t.Fatalf("failed to create issue3: %v", err)
}
if err := store.CreateIssue(ctx, issue4, "test"); err != nil {
t.Fatalf("failed to create issue4: %v", err)
}
// Add dependencies
dep1 := &types.Dependency{IssueID: "bd-1", DependsOnID: "bd-2", Type: types.DepBlocks}
dep2 := &types.Dependency{IssueID: "bd-3", DependsOnID: "bd-2", Type: types.DepBlocks}
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
t.Fatalf("failed to add dependency1: %v", err)
}
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
t.Fatalf("failed to add dependency2: %v", err)
}
// Create collision details (simulated)
collisions := []*CollisionDetail{
{
ID: "bd-1",
IncomingIssue: issue1,
ExistingIssue: issue1,
ReferenceScore: 0, // Will be calculated
},
{
ID: "bd-2",
IncomingIssue: issue2,
ExistingIssue: issue2,
ReferenceScore: 0, // Will be calculated
},
{
ID: "bd-3",
IncomingIssue: issue3,
ExistingIssue: issue3,
ReferenceScore: 0, // Will be calculated
},
{
ID: "bd-4",
IncomingIssue: issue4,
ExistingIssue: issue4,
ReferenceScore: 0, // Will be calculated
},
}
allIssues := []*types.Issue{issue1, issue2, issue3, issue4}
// Score the collisions
err = ScoreCollisions(ctx, store, collisions, allIssues)
if err != nil {
t.Fatalf("ScoreCollisions failed: %v", err)
}
// Verify scores were calculated
// bd-4: 0 references (no mentions, no deps)
// bd-1: 1 reference (bd-1 → bd-2 dependency)
// bd-3: 1 reference (bd-3 → bd-2 dependency)
// bd-2: high references (mentioned in bd-1, bd-3 multiple times + 2 deps as target)
// bd-1 desc (1) + bd-3 desc (3: "bd-2 multiple", "bd-2 and", "bd-2") + bd-3 notes (1) + 2 deps = 7
if collisions[0].ID != "bd-4" {
t.Errorf("expected first collision to be bd-4 (lowest score), got %s", collisions[0].ID)
}
if collisions[0].ReferenceScore != 0 {
t.Errorf("expected bd-4 to have score 0, got %d", collisions[0].ReferenceScore)
}
// bd-2 should be last (highest score)
lastIdx := len(collisions) - 1
if collisions[lastIdx].ID != "bd-2" {
t.Errorf("expected last collision to be bd-2 (highest score), got %s", collisions[lastIdx].ID)
}
if collisions[lastIdx].ReferenceScore != 7 {
t.Errorf("expected bd-2 to have score 7, got %d", collisions[lastIdx].ReferenceScore)
}
// Verify sorting (ascending order)
for i := 1; i < len(collisions); i++ {
if collisions[i].ReferenceScore < collisions[i-1].ReferenceScore {
t.Errorf("collisions not sorted: collision[%d] score %d < collision[%d] score %d",
i, collisions[i].ReferenceScore, i-1, collisions[i-1].ReferenceScore)
}
}
}
func TestCountReferencesWordBoundary(t *testing.T) {
// Test that word boundaries work correctly
allIssues := []*types.Issue{
{
ID: "bd-1",
Description: "bd-10 and bd-100 and bd-1 and bd-11",
},
{
ID: "bd-10",
Description: "bd-1 and bd-100",
},
}
allDeps := map[string][]*types.Dependency{}
tests := []struct {
name string
issueID string
expectedCount int
description string
}{
{
name: "bd-1 exact match",
issueID: "bd-1",
expectedCount: 2, // bd-10's desc mentions bd-1 (1) + bd-1's desc mentions bd-1 (1) = 2
// Wait, bd-1's desc shouldn't count itself
// So: bd-10's desc mentions bd-1 (1)
},
{
name: "bd-10 exact match",
issueID: "bd-10",
expectedCount: 1, // bd-1's desc mentions bd-10 (1)
},
{
name: "bd-100 exact match",
issueID: "bd-100",
expectedCount: 2, // bd-1's desc (1) + bd-10's desc (1)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
count, err := countReferences(tt.issueID, allIssues, allDeps)
if err != nil {
t.Fatalf("countReferences failed: %v", err)
}
// Adjust expected based on actual counting logic
// countReferences skips the issue itself
expected := tt.expectedCount
if tt.issueID == "bd-1" {
expected = 1 // only bd-10's description
}
if count != expected {
t.Errorf("expected count %d, got %d", expected, count)
}
})
}
}
func TestReplaceIDReferences(t *testing.T) {
tests := []struct {
name string
text string
idMapping map[string]string
expected string
}{
{
name: "single replacement",
text: "This references bd-1 in the description",
idMapping: map[string]string{
"bd-1": "bd-100",
},
expected: "This references bd-100 in the description",
},
{
name: "multiple replacements",
text: "bd-1 depends on bd-2 and bd-3",
idMapping: map[string]string{
"bd-1": "bd-100",
"bd-2": "bd-101",
"bd-3": "bd-102",
},
expected: "bd-100 depends on bd-101 and bd-102",
},
{
name: "word boundary - don't replace partial matches",
text: "bd-10 and bd-100 and bd-1",
idMapping: map[string]string{
"bd-1": "bd-200",
},
expected: "bd-10 and bd-100 and bd-200",
},
{
name: "no replacements needed",
text: "This has no matching IDs",
idMapping: map[string]string{
"bd-1": "bd-100",
},
expected: "This has no matching IDs",
},
{
name: "replace same ID multiple times",
text: "bd-1 is mentioned twice: bd-1",
idMapping: map[string]string{
"bd-1": "bd-100",
},
expected: "bd-100 is mentioned twice: bd-100",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := replaceIDReferences(tt.text, tt.idMapping)
if result != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, result)
}
})
}
}
func TestRemapCollisions(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "remap-collision-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Setup: Create an existing issue in the database with a high ID number
// This ensures that when we remap bd-2 and bd-3, they get new IDs that don't conflict
existingIssue := &types.Issue{
ID: "bd-10",
Title: "Existing issue",
Description: "This mentions bd-2 and bd-3",
Notes: "Also bd-2 here",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, existingIssue, "test"); err != nil {
t.Fatalf("failed to create existing issue: %v", err)
}
// Create collisions (incoming issues with same IDs as DB but different content)
collision1 := &CollisionDetail{
ID: "bd-2",
IncomingIssue: &types.Issue{
ID: "bd-2",
Title: "Collision 2 (has fewer references)",
Description: "This is different content",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
ReferenceScore: 2, // Fewer references
}
collision2 := &CollisionDetail{
ID: "bd-3",
IncomingIssue: &types.Issue{
ID: "bd-3",
Title: "Collision 3 (has more references)",
Description: "Different content for bd-3",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
ReferenceScore: 5, // More references
}
collisions := []*CollisionDetail{collision1, collision2}
allIssues := []*types.Issue{existingIssue, collision1.IncomingIssue, collision2.IncomingIssue}
// Remap collisions
idMapping, err := RemapCollisions(ctx, store, collisions, allIssues)
if err != nil {
t.Fatalf("RemapCollisions failed: %v", err)
}
// Verify ID mapping was created
if len(idMapping) != 2 {
t.Errorf("expected 2 ID mappings, got %d", len(idMapping))
}
newID2, ok := idMapping["bd-2"]
if !ok {
t.Fatal("bd-2 was not remapped")
}
newID3, ok := idMapping["bd-3"]
if !ok {
t.Fatal("bd-3 was not remapped")
}
// Verify new issues were created with new IDs
remappedIssue2, err := store.GetIssue(ctx, newID2)
if err != nil {
t.Fatalf("failed to get remapped issue %s: %v", newID2, err)
}
if remappedIssue2 == nil {
t.Fatalf("remapped issue %s not found", newID2)
}
if remappedIssue2.Title != "Collision 2 (has fewer references)" {
t.Errorf("unexpected title for remapped issue: %s", remappedIssue2.Title)
}
// Verify references in existing issue were updated
updatedExisting, err := store.GetIssue(ctx, "bd-10")
if err != nil {
t.Fatalf("failed to get updated existing issue: %v", err)
}
// Check that description was updated
if updatedExisting.Description != fmt.Sprintf("This mentions %s and %s", newID2, newID3) {
t.Errorf("description was not updated correctly. Got: %q", updatedExisting.Description)
}
// Check that notes were updated
if updatedExisting.Notes != fmt.Sprintf("Also %s here", newID2) {
t.Errorf("notes were not updated correctly. Got: %q", updatedExisting.Notes)
}
}
func TestUpdateDependencyReferences(t *testing.T) {
// Create temporary database
tmpDir, err := os.MkdirTemp("", "dep-remap-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
// Create issues
issue1 := &types.Issue{
ID: "bd-1",
Title: "Issue 1",
Description: "First issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue2 := &types.Issue{
ID: "bd-2",
Title: "Issue 2",
Description: "Second issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "bd-3",
Title: "Issue 3",
Description: "Third issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
// Create the new (remapped) issue
issue100 := &types.Issue{
ID: "bd-100",
Title: "Remapped Issue (was bd-2)",
Description: "This is the remapped version",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("failed to create issue1: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("failed to create issue2: %v", err)
}
if err := store.CreateIssue(ctx, issue3, "test"); err != nil {
t.Fatalf("failed to create issue3: %v", err)
}
if err := store.CreateIssue(ctx, issue100, "test"); err != nil {
t.Fatalf("failed to create issue100: %v", err)
}
// Add dependencies
// bd-1 depends on bd-2
dep1 := &types.Dependency{
IssueID: "bd-1",
DependsOnID: "bd-2",
Type: types.DepBlocks,
}
// bd-3 depends on bd-2
dep2 := &types.Dependency{
IssueID: "bd-3",
DependsOnID: "bd-2",
Type: types.DepBlocks,
}
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
t.Fatalf("failed to add dep1: %v", err)
}
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
t.Fatalf("failed to add dep2: %v", err)
}
// Create ID mapping (bd-2 was remapped to bd-100)
idMapping := map[string]string{
"bd-2": "bd-100",
}
// Update dependency references
if err := updateDependencyReferences(ctx, store, idMapping); err != nil {
t.Fatalf("updateDependencyReferences failed: %v", err)
}
// Verify dependencies were updated
// bd-1 should now depend on bd-100
deps1, err := store.GetDependencyRecords(ctx, "bd-1")
if err != nil {
t.Fatalf("failed to get deps for bd-1: %v", err)
}
if len(deps1) != 1 {
t.Fatalf("expected 1 dependency for bd-1, got %d", len(deps1))
}
if deps1[0].DependsOnID != "bd-100" {
t.Errorf("expected bd-1 to depend on bd-100, got %s", deps1[0].DependsOnID)
}
// bd-3 should now depend on bd-100
deps3, err := store.GetDependencyRecords(ctx, "bd-3")
if err != nil {
t.Fatalf("failed to get deps for bd-3: %v", err)
}
if len(deps3) != 1 {
t.Fatalf("expected 1 dependency for bd-3, got %d", len(deps3))
}
if deps3[0].DependsOnID != "bd-100" {
t.Errorf("expected bd-3 to depend on bd-100, got %s", deps3[0].DependsOnID)
}
// Old dependency bd-2 should not have any dependencies anymore
deps2, err := store.GetDependencyRecords(ctx, "bd-2")
if err != nil {
t.Fatalf("failed to get deps for bd-2: %v", err)
}
if len(deps2) != 0 {
t.Errorf("expected 0 dependencies for bd-2, got %d", len(deps2))
}
}
// BenchmarkReplaceIDReferences benchmarks the old approach (compiling regex every time)
func BenchmarkReplaceIDReferences(b *testing.B) {
// Simulate a realistic scenario: 10 ID mappings
idMapping := make(map[string]string)
for i := 1; i <= 10; i++ {
idMapping[fmt.Sprintf("bd-%d", i)] = fmt.Sprintf("bd-%d", i+100)
}
text := "This mentions bd-1, bd-2, bd-3, bd-4, and bd-5 multiple times. " +
"Also bd-6, bd-7, bd-8, bd-9, and bd-10 are referenced here."
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = replaceIDReferences(text, idMapping)
}
}
// BenchmarkReplaceIDReferencesWithCache benchmarks the new cached approach
func BenchmarkReplaceIDReferencesWithCache(b *testing.B) {
// Simulate a realistic scenario: 10 ID mappings
idMapping := make(map[string]string)
for i := 1; i <= 10; i++ {
idMapping[fmt.Sprintf("bd-%d", i)] = fmt.Sprintf("bd-%d", i+100)
}
text := "This mentions bd-1, bd-2, bd-3, bd-4, and bd-5 multiple times. " +
"Also bd-6, bd-7, bd-8, bd-9, and bd-10 are referenced here."
// Pre-compile the cache (this is done once in real usage)
cache, err := buildReplacementCache(idMapping)
if err != nil {
b.Fatalf("failed to build cache: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = replaceIDReferencesWithCache(text, cache)
}
}
// BenchmarkReplaceIDReferencesMultipleTexts simulates the real-world scenario:
// processing multiple text fields (4 per issue) across 100 issues
func BenchmarkReplaceIDReferencesMultipleTexts(b *testing.B) {
// 10 ID mappings (typical collision scenario)
idMapping := make(map[string]string)
for i := 1; i <= 10; i++ {
idMapping[fmt.Sprintf("bd-%d", i)] = fmt.Sprintf("bd-%d", i+100)
}
// Simulate 100 issues with 4 text fields each
texts := make([]string, 400)
for i := 0; i < 400; i++ {
texts[i] = fmt.Sprintf("Issue %d mentions bd-1, bd-2, and bd-5", i)
}
b.Run("without cache", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, text := range texts {
_ = replaceIDReferences(text, idMapping)
}
}
})
b.Run("with cache", func(b *testing.B) {
cache, _ := buildReplacementCache(idMapping)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, text := range texts {
_ = replaceIDReferencesWithCache(text, cache)
}
}
})
}