diff --git a/internal/validation/bead.go b/internal/validation/bead.go index a28b53a3..d9564359 100644 --- a/internal/validation/bead.go +++ b/internal/validation/bead.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) // ParsePriority extracts and validates a priority value from content. @@ -51,6 +52,7 @@ func ValidatePriority(priorityStr string) (int, error) { // ValidateIDFormat validates that an ID has the correct format. // Supports: prefix-number (bd-42), prefix-hash (bd-a3f8e9), or hierarchical (bd-a3f8e9.1) +// Also supports hyphenated prefixes like "bead-me-up-3e9" or "web-app-abc123". // Returns the prefix part or an error if invalid. func ValidateIDFormat(id string) (string, error) { if id == "" { @@ -62,9 +64,11 @@ func ValidateIDFormat(id string) (string, error) { return "", fmt.Errorf("invalid ID format '%s' (expected format: prefix-hash or prefix-hash.number, e.g., 'bd-a3f8e9' or 'bd-a3f8e9.1')", id) } - // Extract prefix (before the first hyphen) - hyphenIdx := strings.Index(id, "-") - prefix := id[:hyphenIdx] + // Use ExtractIssuePrefix which correctly handles hyphenated prefixes + // by looking at the last hyphen and checking if suffix is hash-like. + // This fixes the bug where "bead-me-up-3e9" was parsed as prefix "bead" + // instead of "bead-me-up". + prefix := utils.ExtractIssuePrefix(id) return prefix, nil } diff --git a/internal/validation/bead_test.go b/internal/validation/bead_test.go index a8c303b8..ba613c84 100644 --- a/internal/validation/bead_test.go +++ b/internal/validation/bead_test.go @@ -93,6 +93,16 @@ func TestValidateIDFormat(t *testing.T) { {"bd-a3f8e9.1", "bd", false}, {"foo-bar", "foo", false}, {"nohyphen", "", true}, + + // Hyphenated prefix support + // These test cases verify that ValidateIDFormat correctly extracts + // prefixes containing hyphens (e.g., "bead-me-up" not just "bead") + {"bead-me-up-3e9", "bead-me-up", false}, // 3-char hash suffix + {"bead-me-up-3e9.1", "bead-me-up", false}, // hierarchical child + {"bead-me-up-3e9.1.2", "bead-me-up", false}, // deeply nested child + {"web-app-a3f8e9", "web-app", false}, // 6-char hash suffix + {"my-cool-project-1a2b", "my-cool-project", false}, // 4-char hash suffix + {"document-intelligence-0sa", "document-intelligence", false}, // 3-char hash } for _, tt := range tests { @@ -109,6 +119,90 @@ func TestValidateIDFormat(t *testing.T) { } } +// TestValidateIDFormat_ParentChildFlow tests the exact scenario that fails with --parent flag: +// When creating a child of a parent with a hyphenated prefix, the generated child ID +// (e.g., "bead-me-up-3e9.1") should have its prefix correctly extracted as "bead-me-up", +// not "bead". This test simulates the create.go flow at lines 352-391. +func TestValidateIDFormat_ParentChildFlow(t *testing.T) { + tests := []struct { + name string + parentID string + childSuffix string + dbPrefix string + wantPrefix string + shouldMatch bool + }{ + { + name: "simple prefix - child creation works", + parentID: "bd-a3f8e9", + childSuffix: ".1", + dbPrefix: "bd", + wantPrefix: "bd", + shouldMatch: true, + }, + { + name: "hyphenated prefix - child creation FAILS with current impl", + parentID: "bead-me-up-3e9", + childSuffix: ".1", + dbPrefix: "bead-me-up", + wantPrefix: "bead-me-up", // Current impl returns "bead" - THIS IS THE BUG + shouldMatch: true, + }, + { + name: "hyphenated prefix - deeply nested child", + parentID: "bead-me-up-3e9.1", + childSuffix: ".2", + dbPrefix: "bead-me-up", + wantPrefix: "bead-me-up", + shouldMatch: true, + }, + { + name: "multi-hyphen prefix - web-app style", + parentID: "web-app-abc123", + childSuffix: ".1", + dbPrefix: "web-app", + wantPrefix: "web-app", + shouldMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the child ID generation that happens in create.go:352 + childID := tt.parentID + tt.childSuffix + + // Simulate the validation flow from create.go:361 + extractedPrefix, err := ValidateIDFormat(childID) + if err != nil { + t.Fatalf("ValidateIDFormat(%q) unexpected error: %v", childID, err) + } + + // Check that extracted prefix matches expected + if extractedPrefix != tt.wantPrefix { + t.Errorf("ValidateIDFormat(%q) extracted prefix = %q, want %q", + childID, extractedPrefix, tt.wantPrefix) + } + + // Simulate the prefix validation from create.go:389 + // This is where the "prefix mismatch" error occurs + err = ValidatePrefix(extractedPrefix, tt.dbPrefix, false) + prefixMatches := (err == nil) + + if prefixMatches != tt.shouldMatch { + if tt.shouldMatch { + t.Errorf("--parent %s flow: prefix validation failed unexpectedly: %v\n"+ + " Child ID: %s\n"+ + " Extracted prefix: %q\n"+ + " Database prefix: %q", + tt.parentID, err, childID, extractedPrefix, tt.dbPrefix) + } else { + t.Errorf("--parent %s flow: expected prefix mismatch but validation passed", tt.parentID) + } + } + }) + } +} + func TestParseIssueType(t *testing.T) { tests := []struct { name string