Skip to content

Commit cf9ec08

Browse files
authored
fix(changelog): use semantic versioning for merge sorting (#205)
Fixes #204
1 parent b80a17d commit cf9ec08

4 files changed

Lines changed: 595 additions & 15 deletions

File tree

internal/plugins/changeloggenerator/generator.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"sort"
89
"strings"
910
"text/template"
1011

1112
"github.com/indaco/sley/internal/core"
13+
"github.com/indaco/sley/internal/semver"
1214
)
1315

1416
// Generator handles changelog content generation.
@@ -487,10 +489,24 @@ func (g *Generator) MergeVersionedFiles() error {
487489

488490
// sortVersionFiles sorts version files by semantic version (newest first).
489491
func sortVersionFiles(files []string) {
490-
// Simple reverse lexicographic sort (works for vX.Y.Z format)
491-
for i := 1; i < len(files); i++ {
492-
for j := i; j > 0 && files[j] > files[j-1]; j-- {
493-
files[j], files[j-1] = files[j-1], files[j]
494-
}
492+
sort.Slice(files, func(i, j int) bool {
493+
vi := extractVersion(files[i])
494+
vj := extractVersion(files[j])
495+
// Descending order: higher version first.
496+
return vi.Compare(vj) > 0
497+
})
498+
}
499+
500+
// extractVersion parses a semantic version from a version file path.
501+
// It expects filenames like "v0.10.0.md" and strips the directory, "v" prefix,
502+
// and ".md" suffix before parsing. If parsing fails, it returns a zero-value
503+
// SemVersion so that unparseable filenames sort to the end.
504+
func extractVersion(path string) semver.SemVersion {
505+
base := filepath.Base(path) // "v0.10.0.md"
506+
name := strings.TrimSuffix(base, ".md") // "v0.10.0"
507+
v, err := semver.ParseVersion(name)
508+
if err != nil {
509+
return semver.SemVersion{}
495510
}
511+
return v
496512
}

internal/plugins/changeloggenerator/generator_file_test.go

Lines changed: 179 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,52 @@ func TestMergeVersionedFiles(t *testing.T) {
244244
}
245245
}
246246

247+
func TestMergeVersionedFiles_SemanticOrder(t *testing.T) {
248+
tmpDir, changesDir := setupTestChangesDir(t)
249+
250+
createVersionFiles(t, changesDir, map[string]string{
251+
"v0.1.0.md": "## v0.1.0\n\nFirst version\n",
252+
"v0.1.2.md": "## v0.1.2\n\nPatch release\n",
253+
"v0.2.0.md": "## v0.2.0\n\nSecond minor\n",
254+
"v0.9.1.md": "## v0.9.1\n\nNinth minor patch\n",
255+
"v0.10.0.md": "## v0.10.0\n\nTenth minor\n",
256+
})
257+
258+
changelogPath := filepath.Join(tmpDir, "CHANGELOG.md")
259+
g := createTestGenerator(t, changesDir, changelogPath)
260+
261+
if err := g.MergeVersionedFiles(); err != nil {
262+
t.Fatalf("merge failed: %v", err)
263+
}
264+
265+
content := string(readChangelogContent(t, changelogPath))
266+
267+
// v0.10.0 must appear before v0.9.1 (not between v0.1.2 and v0.2.0)
268+
pos010 := strings.Index(content, "## v0.10.0")
269+
pos091 := strings.Index(content, "## v0.9.1")
270+
pos020 := strings.Index(content, "## v0.2.0")
271+
pos012 := strings.Index(content, "## v0.1.2")
272+
pos010v := strings.Index(content, "## v0.1.0")
273+
274+
if pos010 == -1 || pos091 == -1 || pos020 == -1 || pos012 == -1 || pos010v == -1 {
275+
t.Fatalf("not all versions found in merged content:\n%s", content)
276+
}
277+
278+
// Correct descending order: v0.10.0 > v0.9.1 > v0.2.0 > v0.1.2 > v0.1.0
279+
if pos010 > pos091 {
280+
t.Errorf("v0.10.0 (pos %d) should appear before v0.9.1 (pos %d)", pos010, pos091)
281+
}
282+
if pos091 > pos020 {
283+
t.Errorf("v0.9.1 (pos %d) should appear before v0.2.0 (pos %d)", pos091, pos020)
284+
}
285+
if pos020 > pos012 {
286+
t.Errorf("v0.2.0 (pos %d) should appear before v0.1.2 (pos %d)", pos020, pos012)
287+
}
288+
if pos012 > pos010v {
289+
t.Errorf("v0.1.2 (pos %d) should appear before v0.1.0 (pos %d)", pos012, pos010v)
290+
}
291+
}
292+
247293
func TestMergeVersionedFiles_EmptyDir(t *testing.T) {
248294
tmpDir := t.TempDir()
249295
changesDir := filepath.Join(tmpDir, ".changes")
@@ -546,22 +592,145 @@ Some description about this project.
546592
}
547593

548594
func TestSortVersionFiles(t *testing.T) {
595+
tests := []struct {
596+
name string
597+
input []string
598+
expected []string
599+
}{
600+
{
601+
name: "basic ordering",
602+
input: []string{
603+
"/tmp/.changes/v0.1.0.md",
604+
"/tmp/.changes/v1.0.0.md",
605+
"/tmp/.changes/v0.9.0.md",
606+
},
607+
expected: []string{
608+
"/tmp/.changes/v1.0.0.md",
609+
"/tmp/.changes/v0.9.0.md",
610+
"/tmp/.changes/v0.1.0.md",
611+
},
612+
},
613+
{
614+
name: "double digit minor version sorted semantically not lexicographically",
615+
input: []string{
616+
"/tmp/.changes/v0.1.0.md",
617+
"/tmp/.changes/v0.1.2.md",
618+
"/tmp/.changes/v0.2.0.md",
619+
"/tmp/.changes/v0.10.0.md",
620+
"/tmp/.changes/v0.9.1.md",
621+
},
622+
expected: []string{
623+
"/tmp/.changes/v0.10.0.md",
624+
"/tmp/.changes/v0.9.1.md",
625+
"/tmp/.changes/v0.2.0.md",
626+
"/tmp/.changes/v0.1.2.md",
627+
"/tmp/.changes/v0.1.0.md",
628+
},
629+
},
630+
{
631+
name: "mixed major and minor versions",
632+
input: []string{
633+
"/tmp/.changes/v0.1.0.md",
634+
"/tmp/.changes/v2.0.0.md",
635+
"/tmp/.changes/v0.10.0.md",
636+
"/tmp/.changes/v1.0.0.md",
637+
"/tmp/.changes/v1.10.0.md",
638+
"/tmp/.changes/v1.9.0.md",
639+
},
640+
expected: []string{
641+
"/tmp/.changes/v2.0.0.md",
642+
"/tmp/.changes/v1.10.0.md",
643+
"/tmp/.changes/v1.9.0.md",
644+
"/tmp/.changes/v1.0.0.md",
645+
"/tmp/.changes/v0.10.0.md",
646+
"/tmp/.changes/v0.1.0.md",
647+
},
648+
},
649+
{
650+
name: "single element",
651+
input: []string{
652+
"/tmp/.changes/v1.0.0.md",
653+
},
654+
expected: []string{
655+
"/tmp/.changes/v1.0.0.md",
656+
},
657+
},
658+
{
659+
name: "empty slice",
660+
input: []string{},
661+
expected: []string{},
662+
},
663+
}
664+
665+
for _, tt := range tests {
666+
t.Run(tt.name, func(t *testing.T) {
667+
// Copy to avoid mutating test data
668+
files := make([]string, len(tt.input))
669+
copy(files, tt.input)
670+
671+
sortVersionFiles(files)
672+
673+
if len(files) != len(tt.expected) {
674+
t.Fatalf("expected %d files, got %d", len(tt.expected), len(files))
675+
}
676+
for i, want := range tt.expected {
677+
if files[i] != want {
678+
t.Errorf("position %d: expected %s, got %s", i, want, files[i])
679+
}
680+
}
681+
})
682+
}
683+
}
684+
685+
func TestSortVersionFiles_SemanticOrder_0_10_0(t *testing.T) {
686+
// Regression test: v0.10.0 must sort AFTER v0.9.1 and not between v0.1.2 and v0.2.0.
687+
// This was the original bug: lexicographic comparison treated "10" < "2" because
688+
// "1" < "2" character-by-character.
549689
files := []string{
550-
"/tmp/.changes/v0.1.0.md",
551-
"/tmp/.changes/v1.0.0.md",
552-
"/tmp/.changes/v0.9.0.md",
690+
"/tmp/.changes/v0.1.2.md",
691+
"/tmp/.changes/v0.2.0.md",
692+
"/tmp/.changes/v0.10.0.md",
693+
"/tmp/.changes/v0.9.1.md",
553694
}
554695

555696
sortVersionFiles(files)
556697

557-
// Should be in reverse order (newest first)
558-
if files[0] != "/tmp/.changes/v1.0.0.md" {
559-
t.Errorf("expected v1.0.0.md first, got %s", files[0])
698+
// Newest first (descending semantic order)
699+
expected := []string{
700+
"/tmp/.changes/v0.10.0.md",
701+
"/tmp/.changes/v0.9.1.md",
702+
"/tmp/.changes/v0.2.0.md",
703+
"/tmp/.changes/v0.1.2.md",
560704
}
561-
if files[1] != "/tmp/.changes/v0.9.0.md" {
562-
t.Errorf("expected v0.9.0.md second, got %s", files[1])
705+
706+
for i, want := range expected {
707+
if files[i] != want {
708+
t.Errorf("position %d: expected %s, got %s", i, want, files[i])
709+
}
563710
}
564-
if files[2] != "/tmp/.changes/v0.1.0.md" {
565-
t.Errorf("expected v0.1.0.md third, got %s", files[2])
711+
}
712+
713+
func TestExtractVersion(t *testing.T) {
714+
tests := []struct {
715+
path string
716+
wantStr string
717+
}{
718+
{"/tmp/.changes/v1.2.3.md", "1.2.3"},
719+
{"/tmp/.changes/v0.10.0.md", "0.10.0"},
720+
{"v0.1.0.md", "0.1.0"},
721+
{"/some/path/v2.0.0-rc.1.md", "2.0.0-rc.1"},
722+
// Unparseable returns zero value
723+
{"/tmp/.changes/README.md", "0.0.0"},
724+
{"/tmp/.changes/not-version.md", "0.0.0"},
725+
}
726+
727+
for _, tt := range tests {
728+
t.Run(tt.path, func(t *testing.T) {
729+
v := extractVersion(tt.path)
730+
got := v.String()
731+
if got != tt.wantStr {
732+
t.Errorf("extractVersion(%q) = %q, want %q", tt.path, got, tt.wantStr)
733+
}
734+
})
566735
}
567736
}

0 commit comments

Comments
 (0)