diff --git a/app/app.go b/app/app.go index cbb2ce8..a2e9dc8 100644 --- a/app/app.go +++ b/app/app.go @@ -447,6 +447,10 @@ or: FIND [REPLACE] [PATHS TO FILES AND DIRECTORIES...]` Usage: "Same options as --sort but presents the matches in the reverse order.", DefaultText: "", }, + &cli.BoolFlag{ + Name: "sort-per-dir", + Usage: "Ensure sorting is done per directory", + }, &cli.BoolFlag{ Name: "string-mode", Aliases: []string{"s"}, diff --git a/internal/config/config.go b/internal/config/config.go index bd638dd..832ea6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,6 +82,7 @@ type Config struct { Debug bool `json:"debug"` Recursive bool `json:"recursive"` ResetIndexPerDir bool `json:"reset_index_per_dir"` + SortPerDir bool `json:"sort_per_dir"` } // SetFindStringRegex compiles a regular expression for the @@ -215,6 +216,7 @@ func (c *Config) setDefaultOpts(ctx *cli.Context) error { c.Interactive = ctx.Bool("interactive") c.FixConflictsPattern = ctx.String("fix-conflicts-pattern") c.ResetIndexPerDir = ctx.Bool("reset-index-per-dir") + c.SortPerDir = ctx.Bool("sort-per-dir") if c.FixConflictsPattern == "" { c.FixConflictsPattern = "(%d)" diff --git a/internal/sortfiles/sortfiles.go b/internal/sortfiles/sortfiles.go index bffcba6..d140fff 100644 --- a/internal/sortfiles/sortfiles.go +++ b/internal/sortfiles/sortfiles.go @@ -6,48 +6,54 @@ import ( "cmp" "io/fs" "os" + "path/filepath" "slices" "sort" - "time" "gopkg.in/djherbis/times.v1" "github.com/MagicalTux/natsort" + "github.com/pterm/pterm" "github.com/ayoisaiah/f2/internal/file" "github.com/ayoisaiah/f2/internal/timeutil" ) -// FilesBeforeDirs is used to sort files before directories to avoid renaming +// ForRenamingAndUndo is used to sort files before directories to avoid renaming // conflicts. It also ensures that child directories are renamed before their // parents and vice versa in undo mode. -func FilesBeforeDirs(changes []*file.Change, revert bool) []*file.Change { - sort.SliceStable(changes, func(i, j int) bool { - compareElement1 := changes[i] - compareElement2 := changes[j] +func ForRenamingAndUndo(changes []*file.Change, revert bool) { + slices.SortStableFunc(changes, func(a, b *file.Change) int { + // sort files before directories + if !a.IsDir && b.IsDir { + return -1 + } // sort parent directories before child directories in revert mode if revert { - return len(compareElement1.BaseDir) < len(compareElement2.BaseDir) - } - - // sort files before directories - if !compareElement1.IsDir { - return true + return cmp.Compare(len(a.BaseDir), len(b.BaseDir)) } // sort child directories before parent directories - return len(compareElement1.BaseDir) > len(compareElement2.BaseDir) + return cmp.Compare(len(b.BaseDir), len(a.BaseDir)) }) - - return changes } -// EnforceHierarchicalOrder ensures all files in the same directory are sorted before -// children directories. +// EnforceHierarchicalOrder ensures all files in the same directory are sorted +// before children directories. func EnforceHierarchicalOrder(changes []*file.Change) { slices.SortStableFunc(changes, func(a, b *file.Change) int { - return cmp.Compare(a.BaseDir, b.BaseDir) + lenA, lenB := len(a.BaseDir), len(b.BaseDir) + if lenA == lenB { + // Directories should come after files + if a.IsDir && !b.IsDir { + return -1 + } + + return cmp.Compare(a.Source, b.Source) + } + + return cmp.Compare(lenA, lenB) }) } @@ -57,102 +63,103 @@ func ByTime( changes []*file.Change, sortName string, reverseSort bool, -) ([]*file.Change, error) { - var err error - - sort.SliceStable(changes, func(i, j int) bool { - compareElement1Path := changes[i].RelSourcePath - compareElement2Path := changes[j].RelSourcePath - - var compareElement1, compareElement2 times.Timespec - compareElement1, err = times.Stat(compareElement1Path) - compareElement2, err = times.Stat(compareElement2Path) + sortPerDir bool, +) { + slices.SortStableFunc(changes, func(a, b *file.Change) int { + sourceA, errA := times.Stat(a.RelSourcePath) + sourceB, errB := times.Stat(b.RelSourcePath) + + if errA != nil || errB != nil { + pterm.Error.Printfln( + "error getting file times info: %v, %v", + errA, + errB, + ) + os.Exit(1) + } - var itime, jtime time.Time + aTime, bTime := sourceA.ModTime(), sourceB.ModTime() switch sortName { case timeutil.Mod: - itime = compareElement1.ModTime() - jtime = compareElement2.ModTime() case timeutil.Birth: - itime = compareElement1.ModTime() - jtime = compareElement2.ModTime() - - if compareElement1.HasBirthTime() { - itime = compareElement1.BirthTime() + if sourceA.HasBirthTime() { + aTime = sourceA.BirthTime() } - if compareElement2.HasBirthTime() { - jtime = compareElement2.BirthTime() + if sourceB.HasBirthTime() { + bTime = sourceB.BirthTime() } case timeutil.Access: - itime = compareElement1.AccessTime() - jtime = compareElement2.AccessTime() + aTime = sourceA.AccessTime() + bTime = sourceB.AccessTime() case timeutil.Change: - itime = compareElement1.ModTime() - jtime = compareElement2.ModTime() - - if compareElement1.HasChangeTime() { - itime = compareElement1.ChangeTime() + if sourceA.HasChangeTime() { + aTime = sourceA.ChangeTime() } - if compareElement2.HasChangeTime() { - jtime = compareElement2.ChangeTime() + if sourceB.HasChangeTime() { + bTime = sourceB.ChangeTime() } } - it, jt := itime.UnixNano(), jtime.UnixNano() + if sortPerDir && + filepath.Dir(a.RelSourcePath) != filepath.Dir(b.RelSourcePath) { + return 0 + } if reverseSort { - return it < jt + return -cmp.Compare(aTime.UnixNano(), bTime.UnixNano()) } - return it > jt + return cmp.Compare(aTime.UnixNano(), bTime.UnixNano()) }) - - return changes, err } -// BySize sorts the changes according to their file size. -func BySize(changes []*file.Change, reverseSort bool) ([]*file.Change, error) { - var err error +// BySize sorts the file changes in place based on their file size, either in +// ascending or descending order depending on the `reverseSort` flag. +func BySize(changes []*file.Change, reverseSort, sortPerDir bool) { + slices.SortStableFunc(changes, func(a, b *file.Change) int { + var fileInfoA, fileInfoB fs.FileInfo + fileInfoA, errA := os.Stat(a.RelSourcePath) + fileInfoB, errB := os.Stat(b.RelSourcePath) - sort.SliceStable(changes, func(i, j int) bool { - compareElement1Path := changes[i].RelSourcePath - compareElement2Path := changes[j].RelSourcePath + if errA != nil || errB != nil { + pterm.Error.Printfln("error getting file info: %v, %v", errA, errB) + os.Exit(1) + } - var compareElement1, compareElement2 fs.FileInfo - compareElement1, err = os.Stat(compareElement1Path) - compareElement2, err = os.Stat(compareElement2Path) + fileASize := fileInfoA.Size() + fileBSize := fileInfoB.Size() - isize := compareElement1.Size() - jsize := compareElement2.Size() + // Don't sort files in different directories relative to each other + if sortPerDir && + filepath.Dir(a.RelSourcePath) != filepath.Dir(b.RelSourcePath) { + return 0 + } if reverseSort { - return isize > jsize + return int(fileBSize - fileASize) } - return isize < jsize + return int(fileASize - fileBSize) }) - - return changes, err } // Natural sorts the changes according to natural order (meaning numbers are -// interpreted naturally). -func Natural(changes []*file.Change, reverseSort bool) ([]*file.Change, error) { +// interpreted naturally). However, non-numeric characters are remain sorted in +// ASCII order. +func Natural(changes []*file.Change, reverseSort bool) { sort.SliceStable(changes, func(i, j int) bool { - compareElement1 := changes[i].RelSourcePath - compareElement2 := changes[j].RelSourcePath + sourceA := changes[i].RelSourcePath + sourceB := changes[j].RelSourcePath if reverseSort { - return !natsort.Compare(compareElement1, compareElement2) + return !natsort.Compare(sourceA, sourceB) } - return natsort.Compare(compareElement1, compareElement2) + return natsort.Compare(sourceA, sourceB) }) - - return changes, nil } // Changes is used to sort changes according to the configured sort value. @@ -160,18 +167,22 @@ func Changes( changes []*file.Change, sortName string, reverseSort bool, -) ([]*file.Change, error) { + sortPerDir bool, +) { + // TODO: EnforceHierarchicalOrder should be the default sort + if sortPerDir { + EnforceHierarchicalOrder(changes) + } + switch sortName { case "natural": - return Natural(changes, reverseSort) + Natural(changes, reverseSort) case "size": - return BySize(changes, reverseSort) + BySize(changes, reverseSort, sortPerDir) case timeutil.Mod, timeutil.Access, timeutil.Birth, timeutil.Change: - return ByTime(changes, sortName, reverseSort) + ByTime(changes, sortName, reverseSort, sortPerDir) } - - return changes, nil } diff --git a/internal/sortfiles/sortfiles_test/sortfiles_test.go b/internal/sortfiles/sortfiles_test/sortfiles_test.go new file mode 100644 index 0000000..5bfb034 --- /dev/null +++ b/internal/sortfiles/sortfiles_test/sortfiles_test.go @@ -0,0 +1,803 @@ +package sortfiles_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/ayoisaiah/f2/internal/file" + "github.com/ayoisaiah/f2/internal/sortfiles" + "github.com/ayoisaiah/f2/internal/testutil" + "github.com/ayoisaiah/f2/internal/timeutil" +) + +type sortTestCase struct { + Name string + TimeSort string + Unsorted []string + Sorted []string + Order []string + ReverseSort bool + SortPerDir bool + Revert bool +} + +func sortTest(t *testing.T, unsorted []string) []*file.Change { + t.Helper() + + changes := make([]*file.Change, len(unsorted)) + + for i := range unsorted { + v := unsorted[i] + + changes[i] = &file.Change{ + Source: filepath.Base(v), + BaseDir: filepath.Dir(v), + RelSourcePath: v, + } + + f, err := os.Stat(v) + if err == nil { + changes[i].IsDir = f.IsDir() + } + } + + return changes +} + +func TestSortFiles_EnforceHierarchicalOrder(t *testing.T) { + testCases := []sortTestCase{ + { + Name: "enforce parent-child directory sorting", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/20k.txt", + "testdata/dir1/10k.txt", + "testdata/dir1/folder/15k.txt", + }, + }, + { + Name: "enforce parent-child directory sorting with files and dirs", + Unsorted: []string{ + "testdata/dir1", + "testdata/dir1/folder", + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/20k.txt", + "testdata/dir1", + "testdata/dir1/10k.txt", + "testdata/dir1/folder", + "testdata/dir1/folder/15k.txt", + }, + }, + { + Name: "enforce parent-child directory sorting with multiple files", + Unsorted: []string{ + "f.txt", + "dir1/c.txt", + "dir1/a.txt", + "e.txt", + }, + Sorted: []string{ + "e.txt", + "f.txt", + "dir1/a.txt", + "dir1/c.txt", + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.Name, func(t *testing.T) { + unsorted := sortTest(t, tc.Unsorted) + + sortfiles.EnforceHierarchicalOrder(unsorted) + + testutil.CompareSourcePath(t, tc.Sorted, unsorted) + }) + } +} + +func TestSortFiles_BySize(t *testing.T) { + testCases := []sortTestCase{ + { + Name: "sort in ascending order", + Unsorted: []string{ + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + }, + Sorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + }, + }, + { + Name: "sort in descending order", + Unsorted: []string{ + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + }, + Sorted: []string{ + "testdata/20k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/4k.txt", + }, + ReverseSort: true, + }, + { + Name: "sort recursively without --sort-per-dir ", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/dir1/folder/3k.txt", + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/dir1/10k.txt", + "testdata/11k.txt", + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/dir1/20k.txt", + }, + }, + { + Name: "sort recursively in reverse without --sort-per-dir", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/dir1/20k.txt", + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/dir1/20k.txt", + "testdata/20k.txt", + "testdata/dir1/folder/15k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/10k.txt", + "testdata/4k.txt", + "testdata/dir1/folder/3k.txt", + }, + ReverseSort: true, + }, + { + Name: "sort recursively with --sort-per-dir", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + "testdata/dir1/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/dir1/folder/15k.txt", + }, + SortPerDir: true, + }, + { + Name: "sort recursively in reverse with --sort-per-dir", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/20k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/4k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/10k.txt", + "testdata/dir1/folder/15k.txt", + "testdata/dir1/folder/3k.txt", + }, + SortPerDir: true, + ReverseSort: true, + }, + { + Name: "sort recursively with only directories", + Unsorted: []string{ + "testdata/dir1/folder", + "testdata/dir1", + }, + Sorted: []string{ + "testdata/dir1/folder", + "testdata/dir1", + }, + }, + { + Name: "sort recursively in reverse with only directories", + Unsorted: []string{ + "testdata/dir1/folder", + "testdata/dir1", + }, + Sorted: []string{ + "testdata/dir1", + "testdata/dir1/folder", + }, + ReverseSort: true, + }, + { + Name: "sort size recursively with only directories and --sort-per-dir", + Unsorted: []string{ + "testdata/dir1/folder", + "testdata/dir1", + }, + Sorted: []string{ + "testdata/dir1", + "testdata/dir1/folder", + }, + SortPerDir: true, + }, + { + Name: "sort files and directories without --sort-per-dir", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/dir1", + "testdata/4k.txt", + "testdata/dir1/folder", + "testdata/dir1/folder/3k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/dir1/folder", + "testdata/dir1", + "testdata/dir1/folder/3k.txt", + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/dir1/10k.txt", + "testdata/11k.txt", + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/dir1/20k.txt", + }, + }, + { + Name: "sort files and directories with --sort-per-dir", + Unsorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/20k.txt", + "testdata/dir1", + "testdata/4k.txt", + "testdata/dir1/folder", + "testdata/dir1/folder/3k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/10k.txt", + }, + Sorted: []string{ + "testdata/dir1", + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + "testdata/dir1/folder", + "testdata/dir1/10k.txt", + "testdata/dir1/20k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/dir1/folder/15k.txt", + }, + SortPerDir: true, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.Name, func(t *testing.T) { + unsorted := sortTest(t, tc.Unsorted) + + // sorts the slice in place + sortfiles.Changes( + unsorted, + "size", + tc.ReverseSort, + tc.SortPerDir, + ) + + testutil.CompareSourcePath(t, tc.Sorted, unsorted) + }) + } +} + +func TestSortFiles_Natural(t *testing.T) { + testCases := []sortTestCase{ + { + Name: "sort files numerically", + Unsorted: []string{ + "file10.txt", + "file2.txt", + "file1.txt", + }, + Sorted: []string{ + "file1.txt", + "file2.txt", + "file10.txt", + }, + }, + { + Name: "sort files numerically in reverse", + Unsorted: []string{ + "file1.txt", + "file10.txt", + "file2.txt", + }, + Sorted: []string{ + "file10.txt", + "file2.txt", + "file1.txt", + }, + ReverseSort: true, + }, + { + Name: "sort files with different extensions", + Unsorted: []string{ + "file1.jpg", + "file10.txt", + "file2.png", + }, + Sorted: []string{ + "file1.jpg", + "file2.png", + "file10.txt", + }, + }, + { + Name: "sort files with mixed alphanumeric", + Unsorted: []string{ + "file-2.txt", + "file10.txt", + "file-1.txt", + "file1.txt", + }, + Sorted: []string{ + "file-1.txt", + "file-2.txt", + "file1.txt", + "file10.txt", + }, + }, + { + Name: "sort files with special characters", + Unsorted: []string{ + "file-2.txt", + "file1.txt", + "file_1.txt", + }, + Sorted: []string{ + "file-2.txt", + "file1.txt", + "file_1.txt", + }, + }, + { + Name: "sort files with mixed case", + Unsorted: []string{ + "File10.txt", + "file2.txt", + "FILE1.txt", + }, + Sorted: []string{ + "FILE1.txt", + "File10.txt", + "file2.txt", + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.Name, func(t *testing.T) { + unsorted := sortTest(t, tc.Unsorted) + + // sorts the slice in place + sortfiles.Changes( + unsorted, + "natural", + tc.ReverseSort, + tc.SortPerDir, + ) + + testutil.CompareSourcePath(t, tc.Sorted, unsorted) + }) + } +} + +func TestSortFiles_ByTime(t *testing.T) { + testCases := []sortTestCase{ + { + Name: "sort files by modification time", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + "testdata/dir1/10k.txt", + "testdata/dir1", + "testdata/dir1/folder/3k.txt", + "testdata/dir1/folder/15k.txt", + }, + Sorted: []string{ + "testdata/11k.txt", + "testdata/10k.txt", + "testdata/dir1/10k.txt", + "testdata/20k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/dir1", + "testdata/4k.txt", + "testdata/dir1/folder/15k.txt", + }, + TimeSort: timeutil.Mod, + Order: []string{ + "2025-05-30T06:58:00+01:00", + "2023-03-30T12:30:00+01:00", + "2022-05-30T06:58:00+01:00", + "2023-05-30T12:30:00+01:00", + "2023-04-30T12:30:00+01:00", + "2024-06-20T00:29:00+01:00", + "2024-05-30T06:58:00+01:00", + "2025-06-20T00:29:00+01:00", + }, + }, + { + Name: "sort files by modification time with --sort-per-dir", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + "testdata/dir1/10k.txt", + "testdata/dir1", + "testdata/dir1/folder/3k.txt", + "testdata/dir1/folder/15k.txt", + }, + Sorted: []string{ + "testdata/10k.txt", + "testdata/4k.txt", + "testdata/20k.txt", + "testdata/11k.txt", + "testdata/dir1", + "testdata/dir1/10k.txt", + "testdata/dir1/folder/3k.txt", + "testdata/dir1/folder/15k.txt", + }, + TimeSort: timeutil.Mod, + Order: []string{ + "2023-03-30T12:30:00+01:00", + "2022-05-30T06:58:00+01:00", + "2023-05-30T12:30:00+01:00", + "2023-04-30T12:30:00+01:00", + "2024-06-20T00:29:00+01:00", + "2024-05-30T06:58:00+01:00", + "2025-05-30T06:58:00+01:00", + "2025-06-20T00:29:00+01:00", + }, + SortPerDir: true, + }, + { + Name: "sort files by modification time in reverse", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + }, + Sorted: []string{ + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + "testdata/10k.txt", + }, + TimeSort: timeutil.Mod, + Order: []string{ + "2024-06-20T00:29:00+01:00", + "2022-05-30T06:58:00+01:00", + "2024-05-30T06:58:00+01:00", + "2023-03-30T12:30:00+01:00", + }, + ReverseSort: true, + }, + { + Name: "sort files by access time", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + }, + Sorted: []string{ + "testdata/10k.txt", + "testdata/20k.txt", + "testdata/11k.txt", + "testdata/4k.txt", + }, + TimeSort: timeutil.Access, + Order: []string{ + "2024-06-20T00:29:00+01:00", + "2022-05-30T06:58:00+01:00", + "2024-05-30T06:58:00+01:00", + "2023-03-30T12:30:00+01:00", + }, + }, + { + Name: "sort files by access time in reverse", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + }, + Sorted: []string{ + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + "testdata/10k.txt", + }, + TimeSort: timeutil.Access, + Order: []string{ + "2024-06-20T00:29:00+01:00", + "2022-05-30T06:58:00+01:00", + "2024-05-30T06:58:00+01:00", + "2023-03-30T12:30:00+01:00", + }, + ReverseSort: true, + }, + { + Name: "sort files by birth time", + Unsorted: []string{ + "testdata/4.txt", + "testdata/1.txt", + "testdata/2.txt", + "testdata/3.txt", + }, + Sorted: []string{ + "testdata/1.txt", + "testdata/2.txt", + "testdata/3.txt", + "testdata/4.txt", + }, + Order: []string{ + "testdata/1.txt", + "testdata/2.txt", + "testdata/3.txt", + "testdata/4.txt", + }, + TimeSort: timeutil.Birth, + }, + { + Name: "sort files by birth time in reverse", + Unsorted: []string{ + "testdata/4.txt", + "testdata/1.txt", + "testdata/2.txt", + "testdata/3.txt", + }, + Order: []string{ + "testdata/1.txt", + "testdata/2.txt", + "testdata/3.txt", + "testdata/4.txt", + }, + Sorted: []string{ + "testdata/4.txt", + "testdata/3.txt", + "testdata/2.txt", + "testdata/1.txt", + }, + TimeSort: timeutil.Birth, + ReverseSort: true, + }, + { + Name: "sort files by change time", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + }, + Sorted: []string{ + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + }, + Order: []string{ + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + }, + TimeSort: timeutil.Change, + }, + { + Name: "sort files by change time in reverse", + Unsorted: []string{ + "testdata/4k.txt", + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/20k.txt", + }, + Sorted: []string{ + "testdata/10k.txt", + "testdata/11k.txt", + "testdata/4k.txt", + "testdata/20k.txt", + }, + Order: []string{ + "testdata/20k.txt", + "testdata/4k.txt", + "testdata/11k.txt", + "testdata/10k.txt", + }, + TimeSort: timeutil.Change, + ReverseSort: true, + }, + } + + for i := range testCases { + tc := testCases[i] + + if tc.TimeSort == timeutil.Access || tc.TimeSort == timeutil.Mod { + for i, v := range tc.Unsorted { + mtime, err := time.Parse(time.RFC3339, tc.Order[i]) + if err != nil { + t.Fatal(err) + } + + err = os.Chtimes(v, mtime, mtime) + if err != nil { + t.Fatal(err) + } + } + } + + if tc.TimeSort == timeutil.Birth { + for _, v := range tc.Order { + _, err := os.Create(v) + if err != nil { + t.Fatal(err) + } + + time.Sleep(1 * time.Millisecond) + } + } + + if tc.TimeSort == timeutil.Change { + for _, v := range tc.Order { + err := os.Chmod(v, 0o755) + if err != nil { + t.Fatal(err) + } + + time.Sleep(1 * time.Millisecond) + } + } + + t.Run(tc.Name, func(t *testing.T) { + unsorted := sortTest(t, tc.Unsorted) + + // sorts the slice in place + sortfiles.Changes( + unsorted, + tc.TimeSort, + tc.ReverseSort, + tc.SortPerDir, + ) + + testutil.CompareSourcePath(t, tc.Sorted, unsorted) + + if tc.TimeSort == timeutil.Birth { + t.Cleanup(func() { + for _, v := range tc.Order { + err := os.Remove(v) + if err != nil { + t.Fatal(err) + } + } + }) + } + }) + } +} + +func TestSortFiles_ForRenamingAndUndo(t *testing.T) { + testCases := []sortTestCase{ + { + Name: "sort for file renaming", + Unsorted: []string{ + "testdata/dir1/10k.txt", + "testdata/dir1", + "testdata/4k.txt", + "testdata/dir1/folder/15k.txt", + "testdata/dir1/folder", + }, + Sorted: []string{ + "testdata/dir1/folder/15k.txt", + "testdata/dir1/10k.txt", + "testdata/dir1/folder", + "testdata/4k.txt", + "testdata/dir1", + }, + }, + { + Name: "sort for undo", + Unsorted: []string{ + "testdata/dir1/10k.txt", + "testdata/dir1", + "testdata/4k.txt", + "testdata/dir1/folder/15k.txt", + "testdata/dir1/folder", + }, + Sorted: []string{ + "testdata/4k.txt", + "testdata/dir1", + "testdata/dir1/10k.txt", + "testdata/dir1/folder", + "testdata/dir1/folder/15k.txt", + }, + Revert: true, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.Name, func(t *testing.T) { + unsorted := sortTest(t, tc.Unsorted) + + // sorts the slice in place + sortfiles.ForRenamingAndUndo(unsorted, tc.Revert) + + testutil.CompareSourcePath(t, tc.Sorted, unsorted) + }) + } +} diff --git a/internal/sortfiles/sortfiles_test/testdata/10k.txt b/internal/sortfiles/sortfiles_test/testdata/10k.txt new file mode 100755 index 0000000..9df6499 Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/10k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/11k.txt b/internal/sortfiles/sortfiles_test/testdata/11k.txt new file mode 100755 index 0000000..258e2e6 Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/11k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/20k.txt b/internal/sortfiles/sortfiles_test/testdata/20k.txt new file mode 100755 index 0000000..abdf77b Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/20k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/4k.txt b/internal/sortfiles/sortfiles_test/testdata/4k.txt new file mode 100755 index 0000000..08e7df1 Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/4k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/dir1/10k.txt b/internal/sortfiles/sortfiles_test/testdata/dir1/10k.txt new file mode 100644 index 0000000..9df6499 Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/dir1/10k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/dir1/20k.txt b/internal/sortfiles/sortfiles_test/testdata/dir1/20k.txt new file mode 100644 index 0000000..abdf77b Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/dir1/20k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/dir1/folder/15k.txt b/internal/sortfiles/sortfiles_test/testdata/dir1/folder/15k.txt new file mode 100644 index 0000000..c34bcd5 Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/dir1/folder/15k.txt differ diff --git a/internal/sortfiles/sortfiles_test/testdata/dir1/folder/3k.txt b/internal/sortfiles/sortfiles_test/testdata/dir1/folder/3k.txt new file mode 100644 index 0000000..decc0c3 Binary files /dev/null and b/internal/sortfiles/sortfiles_test/testdata/dir1/folder/3k.txt differ diff --git a/rename/rename.go b/rename/rename.go index 39641be..7ce6e65 100644 --- a/rename/rename.go +++ b/rename/rename.go @@ -215,7 +215,7 @@ func Rename( fileChanges []*file.Change, ) error { if conf.IncludeDir { - fileChanges = sortfiles.FilesBeforeDirs(fileChanges, conf.Revert) + sortfiles.ForRenamingAndUndo(fileChanges, conf.Revert) } if !conf.Interactive && !conf.Exec && !conf.JSON { diff --git a/rename/undo.go b/rename/undo.go index a8bb4c9..6945a91 100644 --- a/rename/undo.go +++ b/rename/undo.go @@ -67,7 +67,7 @@ func Undo(conf *config.Config) error { } // Always sort files before directories when undoing an operation - sortfiles.FilesBeforeDirs(changes, conf.Revert) + sortfiles.ForRenamingAndUndo(changes, conf.Revert) err = Rename(conf, changes) if err != nil { diff --git a/replace/replace.go b/replace/replace.go index dd68d3b..21b5e15 100644 --- a/replace/replace.go +++ b/replace/replace.go @@ -764,10 +764,7 @@ func Replace( slog.Bool("reverse_sort", conf.ReverseSort), ) - changes, err = sortfiles.Changes(changes, conf.Sort, conf.ReverseSort) - if err != nil { - return nil, err - } + sortfiles.Changes(changes, conf.Sort, conf.ReverseSort, conf.SortPerDir) slog.Debug( "updated match order according to sort value",